Skip to content

Architecture & Building ​

The shim architecture ​

KKSS reuses the two VS Code extensions without modifying them. Both are already split into a browser-side webview bundle (whose only VS Code touchpoint is acquireVsCodeApi()), vscode-free compute/parser modules, and a thin vscode-coupled glue layer. KKSS replaces only the glue:

┌────────────────────────── BaseWindow ──────────────────────────┐
│ shell toolbar (Home · mode toggle · Open · title · toasts)     │
│ tab strip (per mode — several open documents, one per tab)     │
├────────────────────────────────────────────────────────────────┤
│ cad tab(s)                      │ mesh tab(s)                  │
│ cad/media/viewer.js (unmodified)│ mesh/media/webview.js (unmod)│
│ + acquireVsCodeApi shim         │ + acquireVsCodeApi shim      │
│ (one WebContentsView per tab)   │ (one WebContentsView per tab)│
└───────────────▲─────────────────┴──────────────▲───────────────┘
                │ IPC = the extensions' own message protocols     │
┌───────────────▼──────────────────────────────────▼─────────────┐
│ Electron main                                                  │
│  one CadHost per open cad tab — port of cad/src/provider.ts    │
│    OCCT + Gmsh WASM → shared worker thread (cadCompute.worker) │
│  one MeshHost per open mesh tab — runs the REAL                │
│    Mdpa/VtkEditorProvider classes behind a `vscode` shim       │
│    module + a fake WebviewPanel; MMG and streamlines → the     │
│    submodule's workers; Flowgraph's child is shared, ref-      │
│    counted across every mesh tab, unchanged                    │
└────────────────────────────────────────────────────────────────┘

A fourth, full-window WebContentsView — the home screen (app/renderer/home/) — is stacked on top and shown on launch (and via the toolbar's Home button, Ctrl+0, or View ▸ Home). It covers the shell and every mode tab; entering a mode hides it. Screens are tracked as Screen = "home" | "editor" | Mode in app/main/ipc.ts and switched with MainWindow.setScreen() (app/main/windows.ts). Each mode screen can hold several open documents ("tabs") — MainWindow.openTab()/closeTab()/setActiveTab() manage a Tab {id, view} registry per mode, one full WebContentsView per tab (the same lazy-create + setVisible() precedent as the terminal/chat panels, just N-of-a-kind); only the focused tab of the active mode is ever visible/bounded, so switching tabs or trips through the home screen never reload or lose a tab's camera/edit-history state. app/main/index.ts's createTab(mode) builds a tab's view and its CadHost/MeshHost together; activeCadHost()/activeMeshHost() resolve whichever tab is currently focused for callers (the native menu, the terminal's cwd, the chat context) that only ever need "the current one." The tab strip is plain DOM inside the shell page (app/renderer/shell/shell.ts), synced wholesale via a {type:"tabs", mode, tabs, activeTabId} message on every open/close/focus/title change — see CLAUDE.md's tabs invariant for the full model, including the two genuinely-shared pieces of state (cadBRepCache.ts's session-keyed worker cache and mesh's ref-counted FlowgraphController). The home menu's buttons are config-driven: add an entry to app/renderer/home/homeConfig.ts, a HomeAction case in app/main/ipc.ts, and its handler in app/main/index.ts (home:toHost/home:toWebview channels via app/preload/homePreload.ts, same contextBridge pattern as the shell). The recents list below those buttons is not config-driven: main pushes it over home:toWebview (sendHome()/pushRecents()), pre-formatted, because that renderer is a browser bundle and cannot import node:path. The renderer re-requests nothing — it replays on its own homeReady, the same handshake the shell uses, so a reload or a crash recovery repaints it.

Interface scale. The shell toolbar's scale picker (and View ▸ Zoom In / Zoom Out / Reset Zoom, Ctrl +/Ctrl -/Ctrl+Shift+0) sets a single zoom factor via MainWindow.setZoom() (app/main/windows.ts). setZoomFactor scales each WebContentsView's content but not its bounds, so layout() multiplies the fixed chrome constants (SHELL_HEIGHT, TAB_STRIP_HEIGHT, TERMINAL_HEIGHT, CHAT_WIDTH) by the factor in lockstep — otherwise the scaled toolbar would clip. TAB_STRIP_HEIGHT is only ever added while a mode screen (which actually has tabs) is active. Electron resets a view's zoom to 1 on every navigation, so each tab's view re-asserts the factor on did-finish-load (a tab reloads when a file opens into it). The picker round-trips over the shell channel (ShellToHost.setZoom / ShellToWebview.zoom), and app/main/index.ts persists it under the uiZoom stateStore key and re-applies it on the next launch (passed into createMainWindow). Presets live in ZOOM_PRESETS — the shell renderer mirrors the same list to build the dropdown.

One instance, one open queue. KKSS takes app.requestSingleInstanceLock() at module load (app/main/index.ts). A second launch does not become a second app: it prints a diagnostic, quits, and its argv reaches the running instance through the second-instance event, which focuses the window and opens the file. This matters beyond convenience — two instances share one userData/state.json, and because the store rewrites that file whole, the loser's first settings write would silently discard everything the winner had changed, including the safeStorage-encrypted API key and the MCP bearer token. macOS's open-file (Finder "Open With", a file dropped on the dock icon) fires before app.whenReady(), so an early path is queued and flushed once the window and its starter tabs exist; the command-line argument drains through the same queue, so there is exactly one "open this path at launch" mechanism. A relative path forwarded by a second instance resolves against that process's working directory, which second-instance supplies. Set KKSS_ALLOW_MULTIPLE_INSTANCES=1 to opt out — tools/e2eShared.mjs does, because the e2e harness relaunches the app repeatedly and SIGKILLs the process tree between runs, and it is the escape hatch for a developer who keeps KKSS open while running npm run smoke or npm run docs:screenshots against the same ~/.config/kkss.

Renderer-crash recovery. wireView() in app/main/windows.ts is the single place every WebContentsView in the window is wired up — it re-asserts the zoom factor on did-finish-load and watches for a dead renderer (render-process-gone, plus a main-frame did-fail-load on one of our own kkss://app/ pages; aborted loads and subresource failures are ignored). It only reports: the policy lives in app/main/index.ts, which owns the host maps and so knows what to replay. Recovery leans entirely on handshakes that already exist — the shell replays screen, tab strips and zoom on shellReady, the editor replays its last document on editorReady, the terminal's termReady reuses the still-running pty, the chat replays from its main-side transcript (now also on disk), and a mode tab re-runs the provider handshake through openPath() (whose disposeSession() is also what rejects the pending promises a crash would otherwise leak). So recovery is a reload, plus a file replay for a mode tab. It is bounded (a few attempts inside a five-minute window) so a reproducible crash cannot become a reload loop, and every recovery raises a toast. unresponsive is deliberately not wired to automatic recovery — it fires on any long synchronous parse, which is exactly what both viewers do on a large mesh, so reloading on it would destroy a working session mid-load. It is reported instead (onViewUnresponsive / onViewResponsive, the latter also on responsive and on a main-frame navigation), and app/main/unresponsiveGuard.ts — Electron-free, every effect injected, test/unresponsiveGuard.test.ts — turns an episode into a Wait / Reload prompt only after the view has stayed unresponsive for 30 s. Wait re-arms with a doubled delay (capped at 10 minutes) so a huge file is not nagged about; Reload is the only path to a reload, runs the crash path's own recoverView (flagged user-initiated: neutral toast wording, not counted against the crash-loop budget), and the prompt's detail warns what a reload discards (unsaved mesh operations, unsaved editor text; CAD edits are kept beside the file). A view that recovers by itself aborts the open dialog through showMessageBox's signal; closing a tab, a reload and quit all end the episode, and the prompt answers gone for a tab that no longer exists.

Flowgraph embedding ​

The mesh submodule's Flowgraph problemtype (view: "flowgraph") splits the MDPA preview's viewport to embed the AGPL-3.0-or-later @kratos-flowgraph/flowgraph node editor in an <iframe>, served by a small Express+EJS app the submodule forks on demand (mesh/src/flowgraphServer.ts, mesh/src/flowgraphController.ts). app/main/index.ts owns the shared, ref-counted FlowgraphController instance — mirroring mesh/src/extension.ts's activate() — and injects it into every MeshHost, which passes it into new MdpaEditorProvider(context, flowgraph, runs, recents) (the VTK provider also takes (context, flowgraph, runs, recents) and owns a Problemtype controller for non-MDPA formats) and disposes it on Electron's will-quit so the forked child process never outlives the app. runs is the shared RunManager and recents mesh 3.15.0's RecentMeshStore — the same construct-once-inject-everywhere rule, for the same reason: a solve, and the recent-file list, both outlive the tab they came from.

Path contract: like the MMG worker pair, flowgraphController.ts resolves its server and assets via __dirname, so esbuild.mjs's copyArtifacts() places out/flowgraphServer.js and the out/flowgraph/ asset tree (copied from mesh/dist/flowgraph/ — Flowgraph's public/+ views/, its LICENSE, and our vscode-bridge.js) directly beside out/main.js.

Extended mesh formats (meshio++) ​

The mesh submodule reads and writes extended mesh formats (Gmsh, Abaqus, Nastran, I-deas UNV, Medit, Netgen, SU2, XDMF, COMSOL, tetgen, EnSight Gold, Triangle, Exodus II, CGNS, MOAB, Salome MED, …) through @meshioplusplus/wasm (17.13.0, the version KKSS stages and runs) — meshio++'s C++ core compiled to WebAssembly. meshio++ adds the field-only .dex/.ip/.mff formats: they carry point fields with no cell geometry, so writing one keeps the points plus a field and drops connectivity, and reading one yields a point cloud (or an empty mesh); plus write-only SVG/TikZ figure formats (a 2D/3D-projected drawing of the mesh) surfaced in the export menu's "Figures" group. Since 8.5.0 the WASM statically links HDF5 and netCDF, which is what makes Exodus/CGNS/H5M/HMF/MED reachable — and since 8.6.0 a multi-step file exposes its steps through ReadOptions.timeStep / MeshMetadata.timeValues, which is the in-file timeline (9.9.0 added timeStep for MED too; since 11.3.0/12.0.0 MED, CGNS and Tecplot also report header-only metadata, so all of them can size their in-file timeline before a read). 9.9.0 also made MED a writable format and let SubModelParts survive an export to MED/Abaqus, by fixing the shapeless-data boundary that silently reshaped an (n, 3) vector field into (3n, 1) on the way into the WASM. It is ESM-only (its Emscripten glue reads import.meta.url), so the submodule keeps it external and ships it verbatim as the mesh/dist/meshio/ tree, and mesh/src/parser/meshio.ts loads it through a runtime dynamic import() rather than a bundled require.

Path contract: meshio.ts's packageDir() falls back to path.join(__dirname, "meshio"), and meshio.ts is bundled into both out/main.js (mesh host → meshFileParser/meshWriter) and out/mcpServer.js — both with __dirname === out/. So copyArtifacts() mirrors mesh/dist/meshio/ to a single out/meshio/ tree beside out/main.js, serving the app host and the MCP server at once. The .wasm is loaded via meshio++'s locateFile hook (the wasmBinary buffer hook MMG uses is unavailable in this build), which is why out/ stays unpacked (asar: false). @meshioplusplus/wasm is also added to the parent mainConfig.external in esbuild.mjs, because the bundled meshio.ts contains a require.resolve("@meshioplusplus/wasm/package.json") literal esbuild would otherwise try to resolve at build time.

Both WASM variants must ship. Since meshio++ 8.8.0 the package carries a threaded build (meshioplusplus_wasm_mt.{mjs,wasm}, ~+6.2 MB) alongside the sequential one, and its resolveVariant() picks the threaded one under Node — which is what KKSS's main process and out/mcpServer.js are. Shipping only the sequential pair makes every extended format fail with an opaque LinkError. The mesh submodule's own copy plugin emits all four files into mesh/dist/meshio/, and copyArtifacts() mirrors that tree wholesale (fs.cpSync(..., { recursive: true })), so no parent-side enumeration needs updating — but after a submodule bump, check out/meshio/dist/meshioplusplus_wasm* really lists four files.

One tree, two submodules — the version skew is a standing risk. CAD 3.30.0 declares 17.13.0 and mesh 5.22.0 declares ^17.13.0, so the single staged out/meshio/ tree serves both engines from one version (the 17.2-era skew is closed). The staged tree wins at runtime (cad resolves ../../meshio from its kernel worker), which is deliberate — but a mesh-only bump like this one silently moves what CAD runs on. test/cadMcpRuntime.test.ts (real OCCT + meshio through the bundled server and its forked worker) and test/meshMcpRuntime.test.ts are the gate for exactly this: both must pass after every bump, and a green build says nothing about it. When either side moves, re-read the other side's package.json pin before assuming the pair is still compatible — and prefer re-aligning CAD's pin over letting the skew grow. Mesh 5.22.0 uses the 17.13.0 tree (the 17.9.0–17.13.0 bumps add KaHIP partitioning, the octree and refinePatches operations, six planar-domain generators and deeper fitCad options with no new reader/writer keys: the lean artifact's live readers are 81/82, full 83/82); CAD 3.30.0 adds the octreemesh engine and mesh_cad_surface on the same tree (staged tree ~91 MB raw, KaHIP linked, no new worker files).

Native menu enabled states are rebuilt after every screen switch, including file-open routing. Rebuilding only when recent files changed left mesh actions disabled on the first mesh open because that notification preceded the mode change. The smoke suite checks packing availability after file open and CAD/mesh switches.

Submodule release maintenance ​

Keep both gitlinks at the latest releases, preserving the kkss.dev integration commits. A successful build alone does not establish that the new capabilities work.

  1. Review upstream release notes and the source diff. Keep app-only behavior in KKSS. Required MCP contracts belong on the submodule's kkss.dev branch and extension changes on application-downstream; commit those upstream changes before the gitlink bump here.
  2. Run npm run build: it builds CAD and runs npm run package --prefix mesh before the parent build copies artifacts. Check that out/meshio/dist/meshioplusplus_wasm{,_mt}.{mjs,wasm} contains all four files, and that out/ftetwild/ came from cad/dist/ftetwild rather than cad/node_modules. Since cad 3.11.0 also check out/cad-runtime/dist/mmg-core.wasm (the remesh binary, from cad/dist/mmg-core.wasm) and out/mmg/dist/mmg.cjs (the MMG JS wrapper, from cad/node_modules/@loumalouomega/mmg-wasm/dist/ — KKSS ships no node_modules, so the staged tree is the only runtime resolution left). Since cad 3.14.0 also check out/cad-runtime/dist/draco/ (web pair + Node wasm + licence, from cad/dist/draco/) and out/renderer/dist/draco/ (web pair for the interactive viewer, whose ../dist/draco/ lookup resolves under out/renderer/). Since mesh 5.5.0 also check out/{analysisWorker,seriesScanWorker,oracleOpWorker,meshioWriteWorker}.js (the cancellable worker fleet, from mesh/dist/, same beside-main.js contract as MMG/streamline), since mesh 5.10.0 out/meshioReadWorker.js (the meshio++ read worker — configureMeshioReadRunner in meshHost.ts is what moves extended-format reads off the main process, so a missing copy is a silent freeze rather than a build failure), and since mesh 5.18.0 out/derivedWorker.js (the derived-mesh / partition worker — configureDerivedRunner in meshHost.ts is what moves slice/isosurface/grid/decimate and curve/KaHIP partition export off the main process, with the same silent-freeze failure mode). Since mesh 5.13.0 the staged tree also carries the FULL meshio++ artifact: out/meshio/dist/meshioplusplus_wasm{,_mt,_full,_full_mt}.{mjs,wasm} (eight files) plus THIRD_PARTY_NOTICES.md beside it — the full pair is where OpenCASCADE (STEP/IGES reads, fitCad), zstd/lz4, bzip2 and KaHIP live, so a missing copy breaks the mesh→CAD fit, the KaHIP partitioner and the codec fallbacks rather loudly, and the package is ~80 MB raw (the installer grows accordingly). Exercise an extended-format read/write through the copied runtime. Since mesh 4.8.0 the build also downloads and prepares the VTK-wasm runtime (~13 MB fetch, ~86 MB staged, re-pinned to 9.7.20261018 with this bump); out/renderer/mesh/vtk-wasm/ is optional, so its absence is a warning, not a failure, and KRATOS_VTK_WASM=skip is the explicit opt-out. Since cad 3.22.0 also check the three Manifold copies of manifold.wasm + its Apache-2.0 licence (from cad/dist/manifold/): out/cad-runtime/dist/manifold/ (the kernel worker's manifoldNode.ts reads it from beside its own bundle, and the headless render_snapshot page at out/cad-runtime/media/ resolves ../dist/manifold/), out/renderer/dist/manifold/ (the interactive viewer's manifoldBrowser.ts, same ../dist/ contract as Draco), and out/manifold/ (manifoldNode.ts's __dirname fallback for the OCCT-less compute worker, whose __dirname is out/). Since cad 3.26.0 also check the OpenSCAD port: out/cad-runtime/dist/openscad-core.wasm (the 24 MB binary, from cad/dist/openscad-core.wasm, passed explicitly as wasmBinary and separately replaceable) plus the glue trees out/openscad/dist/ (interactive worker, __dirname = out/) and out/cad-runtime/openscad/dist/ (MCP kernel worker) — both copied from cad/node_modules/@loumalouomega/openscad-wasm/dist/ minus the bundled openscad-core.wasm copy — with LICENSE/NOTICE/THIRD_PARTY_NOTICES.md beside the interactive tree (GPL-3.0-or-later attribution, same as cad's VSIX carve-outs).
  3. Compare tools/webviewMarkup.ts's mesh body element for element with mesh/src/webviewChrome.ts's buildPreviewHtml, using its exported fragments. Preserve the KKSS stylesheets, shim-before-bundle order and deliberate omission of startEmpty — and of the renderer attributes, which the host injects per load. The build's theme guard checks new --vscode-* and --ds-* uses.
  4. Audit reachable VS Code APIs and commands against app/main/vscodeShim.ts; extend the shim when needed, keeping unsupported commands loud. Every action available only from the hidden mesh menubar needs a native-menu route. Also check new palette/tree-only capabilities, such as time-series packing.
  5. Start the copied CAD and mesh MCP servers with the environment from serverSpecs() in mcpManager.ts, list their actual tools and compare both missing and obsolete names against toolPolicy.ts. Classify new tools deliberately; the pinned test checks the policy table, not the live upstream tool list. Verify new viewer capabilities through MCP too, and update the stable system-prompt capability description for new app abilities. Settings and current-file context must stay aligned where applicable.
  6. Run npm run typecheck, npm test, npm run check:packaging, npm run smoke (under xvfb-run -a on headless Linux), and npm run docs:build. Exercise the release's new operations with upstream fixtures and temporary outputs. Update user guides, README and project memory; regenerate screenshots with npm run docs:screenshots if viewer markup or visible behavior changes. test/settingsRegistry.test.ts fails until every contributed setting of both submodules is either a registry row with a matching type/enum/default or listed in NOT_APPLICABLE with a reason — and a row only counts as wired if something reads it, which is why the renderer is injected by app/main/mesh/rendererPage.ts rather than merely registered, and offered as a View ▸ 3D Renderer quick radio on the same row. A setting that needs a native-menu route as well as a row needs both: the menu reads the same registry entry (enumRadio), so the two cannot drift, and the menu's own gates (enabled, per-value disabledValue) belong there rather than in the row. node tools/renderer-probe.mjs drives that path live where a headless runner cannot.

The CAD MCP kernel is built from the unchanged cad/src/kernelWorker.ts by cadMcpWorkerConfig in esbuild.mjs. It shares the interactive worker's Gmsh CJS alias, but lives at out/cad-runtime/dist/kernel-worker.js, beside OCCT/Gmsh/MMG WASM; since cad 3.6.0 its meshio++/fTetWild lookups resolve ../../meshio and ../../ftetwild from there through cad's own runtimePackage.ts, with no KKSS-side shim. Since cad 3.11.0 its MMG wrapper lookup resolves ../../mmg the same way (the out/mmg/ tree). Since cad 3.26.0 its OpenSCAD glue lookup resolves the same way through the single out/openscad/ tree (<bundle dir>/openscad for the interactive worker, two levels up for this kernel worker); the binary itself is out/cad-runtime/dist/openscad-core.wasm. Do not overwrite it with cad/dist/kernel-worker.js: that upstream artifact expects external npm packages shipped by the extension, which KKSS does not include. test/cadMcpRuntime.test.ts checks real OCCT and meshio calls through the bundled server and forked worker.

Format counts depend on whether extensions, reader keys or writer keys are counted. Use mesh/src/parser/meshioFormats.ts's read/write routing tables and mesh/src/parser/writers/exportFormats.ts for the supported surfaces instead of a shared approximate count. Packed time series use an .xdmf index plus a required sibling .h5; keep both when copying results.

About dialog & updates ​

Help ▸ About KKSS… (and the home screen's Help button) opens a frameless singleton window (app/main/services/about.ts, same pattern as the modal picker) backed by app/renderer/about/ over about:init / about:toHost / about:toWebview (app/preload/aboutPreload.ts). It shows the version (app.getVersion()), the author (injected from package.json by an esbuild define), and an update check.

Update flow (app/main/services/updates.ts):

  • Availability — the GitHub REST API (releases/latest) + a semver compare; works in dev runs too. Offline / rate-limited / bad tags degrade to a "Couldn't check for updates" line with Retry — never a crash.
  • Delivery — electron-updater (GitHub provider), only where the app can self-replace: the Windows NSIS install and the Linux AppImage. .deb installs and the (unsigned) macOS builds get an "Open releases page" button instead, as does any runtime updater failure.
  • Both semver and electron-updater are devDependencies bundled into out/main.js by esbuild — the package still ships no node_modules.

The feed plumbing electron-updater needs: the publish: block in electron-builder.yml makes electron-builder emit latest*.yml into release/ and embed resources/app-update.yml in each package (even with --publish never), and .github/workflows/release.yml uploads release/latest*.yml + release/*.blockmap so they land on the GitHub Release next to the installers. Remove either half and in-app updates stop finding releases.

A Flatpak build is deliberately only a probe for now: Flatpak feasibility probe explains what npm run flatpak:probe checks (unpacked workers and WASM, the native node-pty module, the sandbox permissions still to narrow) and why no Flatpak artifact is published.

What's New / changelog dialog ​

app/main/services/whatsNew.ts shows a frameless singleton window (same pattern as the About dialog) backed by app/renderer/whatsnew/ over whatsNew:init / whatsNew:toHost (app/preload/whatsNewPreload.ts). checkForNewVersion() runs once at startup (app/main/index.ts): it compares the lastSeenVersion stateStore key against app.getVersion() and, if the version changed, shows the CHANGELOG.md entries newer than the last-seen one (semver.gt per entry). It stays silent on a fresh install (nothing to diff against yet) and under the e2e smoke test (KKSS_E2E). Help ▸ What's New… (showChangelog()) reopens the full history on demand, regardless of version.

Content comes straight from the repo's CHANGELOG.md — esbuild.mjs's copyArtifacts() copies it verbatim to out/CHANGELOG.md (read via __dirname next to out/main.js, same path-contract pattern as the other out/-relative assets), and app/main/services/changelog.ts's parseChangelog() — kept electron-import-free like updateCheck.ts, so test/changelog.test.ts can exercise it directly — splits it on the ## [X.Y.Z] - YYYY-MM-DD headings the CLAUDE.md changelog-sync rule enforces. Keeping that format is what keeps this dialog's content accurate; a heading that doesn't match the pattern is silently skipped.

License window ​

Help ▸ Examples ▸ <case> (app/main/services/examples.ts, pure core examplesCore.ts) unpacks a shipped tutorial case: esbuild.mjs's exampleCopies() stages each doc/public/examples/tutorials/<id>.zip (plus LICENSE) into out/examples/; the service asks for a folder, confirms before replacing an existing <id>/ directory, writes only members that planExample accepted (everything under <id>/, no traversal/absolute paths, ≤500 files and 100 MB), then opens the geometry (.brep/.stp, else mesh.mdpa) through openFile. The project root is left alone and no results open — solver output isn't shipped. Labels are typed i18n keys in both locales.

Help ▸ License… (app/main/services/license.ts) is the About pattern at its smallest: a frameless singleton over app/renderer/license/ whose one message back is {type: "close"} (license:toHost, app/preload/licensePreload.ts). The text it shows is the repo's LICENSE — copyArtifacts() copies it to out/LICENSE beside CHANGELOG.md, the service reads it via __dirname and sends the whole file inside one license:init message ({version, text}), and the renderer puts it in a selectable <pre>. The window title and subtitle come from the KKSS License / Full license text, as shipped with this build (version {0}). i18n keys, so the page and the menu travel together in both locales.

The in-app manual (KKSS Manual…) ​

Help ▸ KKSS Manual… (app/main/services/manual.ts) opens the documentation site inside the app: a resizable window on kkss://docs, the protocol branch in app/main/protocol.ts that serves the built site from out/docs/ — doc/.vitepress/dist, staged by esbuild.mjs's copyArtifacts() on every build (optional like the VTK-wasm runtime: without it the handler serves manualFallbackHtml(), whose one line is "run npm run docs:build"; packaged installs always ship a built manual because the build stages it).

Three rules make the site work served rather than hosted:

  • The base never moves. The built pages carry the site's public base (vitepress.config's base: '/KKSS/') in every asset and page link, and VitePress's client router resolves against location.pathname — so the manual URL keeps the base (kkss://docs/KKSS/…, manual.ts's MANUAL_ROOT_URL) and the protocol branch strips SITE_BASE before mapping to disk (resolveDocsPath, app/main/services/docsServe.ts, pinned by test/docsServe.test.ts against path traversal). Change the docs base and docsServe.ts's SITE_BASE moves with it; the test fails if the constant and a built dist/index.html disagree.
  • The ~28 MB of downloadable examples stay a web concern. copyArtifacts() stages everything but examples/, and rewrites the built pages' <a href> example links to the published site's URLs (rewriteExampleLinks, the same pinned module) — manual.ts intercepts will-navigate off kkss://docs and opens those (and any outside URL) in the OS browser instead of replacing the manual.
  • A distinct origin. The manual lives on the kkss://docs host label (never kkss://app), so the site's scripts are not kkss://app-origin siblings of the app's own pages; the window ships no preload bridge.

test/helpMenu.test.ts pins the menu order (Manual → Documentation → submodule links → License → What's New → About) and both i18n key sets.

Guided workflow services ​

app/main/services/workflows/ contains the app-owned v1 project, environment and queue contracts. The Home renderer and app__* tools call the same WorkflowService; the in-process tool registry is included by the shared MCP manager, so chat and the optional HTTP meta server see the same tool definitions and approval policy. Dynamic chat context includes the selected project/study identity. ProjectStore serializes read/modify/write updates and uses the existing atomic file writer; unknown project schema versions are preserved and rejected. Project-relative artifact references resolve against the selected root after folder moves, while external paths remain marked as external.

The environment probe runs a bounded Python import/version check separately for manual runs and the uvx tool runtime. The uvx probe uses offline/no-sync flags and never installs packages. KKSS wires the shared report into mesh's RunManager preflight: PtController publishes capability to the webview, which disables only Run when the case requirements fail, and RunManager.start forces a fresh check before dispatch. With no guard configured, the standalone extension retains its prior behavior. CAD MDPA exports produce a versioned handoff manifest and atomic owner/request receipts with artifact revisions; mesh queue runs snapshot inputs into isolated workspaces with stable owner/request receipts and owner-scoped cancellation. The persistent queue stores dispatch intent before calling a runner, reconciles CAD mesh and solver request IDs, and leaves unresolved work uncertain rather than resubmitting it. Terminal runs can be imported into immutable project-local input snapshots; large result files remain revision-checked references. Structural case generation writes a versioned per-step convergence monitor. mesh__case_evaluate_quantity uses the existing field parser and reductions to evaluate one explicitly selected field/component, region, time and reduction. app__run_quantity_evaluate requires an explicit unit, verifies the run and result revision, and stores the quantity with a portable source reference. Reviews omit values whose source result is stale; variant comparisons require matching definitions and units, and classify mesh-sensitivity separately from solver-setting changes using recorded mesh revisions. The Home row list can resume one waiting run row at a time while holding other waiting work, or preview a failed row's retry as a new study/run identity in the existing comparison group.

Queued solve tasks freeze kratos.threads into their arguments, revision and verified CPU reservation. mesh/src/problemtype/threadControl.ts applies and reads the Kratos OpenMP count before runpy starts the generated analysis stage; the atomic kkss-resources.json receipt records the effective allocation. The queue retains active/uncertain reservations across restart and admits at most one solve and one mesh/case preparation task when their checked allocations fit availableParallelism(). CAD Gmsh and serial fTetWild preparation are each bounded to one thread; task types with no verified cost run alone.

mesh/src/problemtype/mainKratosTemplate.ts shares versioned monitor framing across the structural, fluid, convection-diffusion, potential-flow and shallow-water built-ins. outputDiscovery.ts reads generated output process settings and finds supported VTK, XDMF/HDF5 and GiD entrypoints and companions inside the isolated run workspace. Review checks every owned companion revision before accepting a saved quantity. Unknown processes, unsafe destinations and missing companions remain explicit findings. Mesh counts use the MDPA text scanner or mesh__mesh_info for other routed formats.

refinement.ts converts user-declared revision-bound sizes into metres and computes unequal-ratio observed order, Richardson extrapolation and safety-factor 1.25 GCI. Comparisons require identical solver settings/resource allocations, geometry revision, compatible definitions/units, distinct meshes, current statistics and explicit sizing, comparability and asymptotic assumptions. Four-level asymptotic consistency uses a visible 10% heuristic; a failed check clears all estimates. HTML review/comparison reports render escaped tables and include an offline JSON twin.

tools/workflows/benchmark.mjs repeats every published tutorial solver not marked benchmark: false in tools/tutorials/cases.mjs at 1, 2, 4 and all available CPUs (the recorded run in doc/public/benchmarks/thread-benchmark.json covers five single-physics cases), verifies solver physics/thread receipts/monitor closure, and records coordinator event-loop delay as a responsiveness proxy. tools/workflows/outputs.mjs runs live VTK, GiD and XDMF/HDF5 output and quantity acceptance against the pinned tutorial interpreter; materials come from tools/tutorials/cases.mjs rather than the published fixtures. The XDMF case reports solver, discovery (entrypoint plus every .h5 companion) and read-back separately: at present it passes the first two and records a read-back failure, because meshio++ rejects Kratos's temporal XDMF (see the roadmap). Kratos's HDF5 application needs the MPICH-ABI libmpi.so.12 (the mpich wheel's lib/ directory on LD_LIBRARY_PATH), and on 10.4.2–10.4.4 the stock single_mesh_xdmf_output_process fails (Failed to open file) because its XDMF step opens the mesh file for real in exclusive mode, while TimeLabel() loses the time of any file whose stem ends in e — a model part called Structure collapses every step to time 0. The harness therefore sets file_settings {file_name: "<model_part_name>_results-<time>", file_access_mode: "read_write"} and HDF5_USE_FILE_LOCKING=FALSE, which yields a correct index. Set KKSS_TUTORIAL_PYTHON (and LD_LIBRARY_PATH as above), then run npm run workflows:benchmark -- doc/public/benchmarks/thread-benchmark.json and npm run workflows:outputs. Timing samples are machine-specific; the benchmark reports them without treating higher thread counts as inherently faster.

On the recorded AMD Ryzen 7 255 system (16 available CPUs), three-run median solve times in milliseconds were:

Tutorial1 thread2 threads4 threads16 threads
Structural531473440589
Fluid8938528871,135
Thermal777879143
Potential flow848787106
Shallow water868790151

The median coordinator p99 delay was 20.5 ms at a 20 ms sampling resolution; it is not a GUI frame-rate measurement. The full per-run verification and timing samples are in the linked JSON. Reviews include revision-checked generated inputs and the mesh runner's existing quality report. The versioned structural monitor records solver-published residual norms, criterion parameters, and solve-step coordinates for nonlinear residual-criterion runs. Linear solves, other criteria, interrupted runs, and truncated monitor files remain explicitly unavailable; process exit alone does not establish convergence. The preparation report records effective settings, units, validation findings, generator/runtime identity, source and mesh revisions, and hashes of generated inputs.

The real structural cantilever acceptance harness is opt-in because it launches Gmsh and Kratos. After building the app and submodules and preparing the repository's isolated Python 3.12/Kratos runtime, run:

sh
KKSS_RUN_TIER4_ACCEPTANCE=1 \
KKSS_TIER4_PYTHON="$PWD/node_modules/.cache/kkss-tier4/runtime/bin/python" \
npx vitest run test/tier4Acceptance.test.ts

The harness imports the CAD fixture through the actual CAD MCP server, generates a tetrahedral mesh, generates and solves the structural case through the shared workflow service, reviews solver residual evidence, evaluates tip displacement, duplicates the solved study, then resumes a two-row parameter sweep across a service restart. The Euler–Bernoulli reference displacement for its 18 × 4 × 5 mm cantilever is approximately 0.00059986 mm; the first-order tetrahedral result is accepted within a 25% discretization tolerance (the recorded run was within 2%). The separate workflow tests cover interrupted dispatch, dependency failures, owner-scoped cancellation, moved projects, unknown schemas, and immutable run snapshots.

Embedded terminal (node-pty + xterm.js) ​

The Terminal toolbar button / Ctrl+` toggles a bottom panel WebContentsView (lazily created in app/main/windows.ts; layout() shrinks the mode views by TERMINAL_HEIGHT while it's shown). The renderer (app/renderer/terminal/, @xterm/xterm + fit addon) talks to app/main/services/terminal.ts over term:toHost / term:toWebview (app/preload/terminalPreload.ts): one node-pty session shared by both modes, spawned on first show in the current file's directory — PowerShell on Windows, $SHELL elsewhere, overridable via Settings ▸ Terminal Shell (stateStore key terminalShell) — kept alive while hidden, and ended on quit: before-quit waits for the shell's exit (SIGHUP, then SIGKILL after 1.5 s, in app/main/services/terminalShutdown.ts) before Electron tears down. The renderer offers an Enter-to-restart when the shell exits.

node-pty is the app's only native module, and the only node_modules entry that ships in the package (see the files rules in electron-builder.yml; asar: false means the .node binaries load directly). It is N-API, so no Electron-ABI rebuild step exists or is needed — Windows/macOS use the prebuilt binaries shipped in the npm package, Linux compiles once during npm ci (GitHub runners and typical dev boxes have the toolchain). Three consequences to keep in mind:

  • package.json's allowScripts must keep the node-pty@… entry — without it the install scripts are skipped and the binaries never materialize.
  • The release workflow builds on one runner per OS/arch (ubuntu-24.04-arm, windows-11-arm for the arm64 targets): Linux needs a native compile and Windows assembles arch-specific ConPTY binaries at install time, so cross-arch packaging from a single runner is no longer possible.
  • A live shell's exit must be observed before will-quit. node-pty delivers exits through a native callback with no exception guard, so an exit that lands during teardown aborts the whole process. before-quit therefore awaits TerminalService.shutdown(), which is bounded, and the exit listener never throws. The quit hold waits only once no dirty prompt can still cancel the quit, so a cancelled quit keeps the shell running.

CSP note: xterm.js injects <style> elements at runtime, so app/renderer/terminal/index.html allows 'unsafe-inline' styles — this page only; every other page keeps the strict style-src kkss:.

Text editor (CodeMirror 6) ​

The editor screen (Screen = "home" | "editor" | Mode) is a WebContentsView with body bounds — the shell toolbar stays visible and the terminal panel shares space with it. app/renderer/editor/ bundles CodeMirror 6 (codemirror basic setup + @codemirror/lang-json/lang-python

  • one-dark theme); all fs work lives in app/main/services/editor.ts behind editor:toHost / editor:toWebview (app/preload/editorPreload.ts) — the renderer never touches the filesystem. File ▸ Save / Save As route to the editor when it's the active screen (main.screen()), and the in-page CodeMirror keymap binds Mod-s for the focused case. Dirty handling: the buffer survives screen switches (views are only hidden), so prompts fire only on the destructive paths — window close (Save / Don't Save / Cancel) and opening another file over unsaved changes. Like the terminal page, the editor page allows 'unsafe-inline' styles (CodeMirror injects <style> at runtime).

Kratos background jobs ​

app/main/services/jobs.ts observes the pinned Kratos server through the same McpHub used by chat and the HTTP meta server. It calls only job_list, job_status, job_logs, and user-requested job_cancel. Structured results (including FastMCP's result wrapper) and JSON text are validated; error objects are failures. Records stay owned by the server, with no new local store or solver process ownership.

The Jobs view is created lazily and shares the right-hand sidebar area with Chat. Opening either hides the other without stopping work. Typed jobs:toHost / jobs:toWebview messages carry ready, refresh, selection, cancellation, runtime recovery and hide requests, and full snapshots. The service verifies renderer identity and known job IDs. A panels shell message ({terminal, chat}, from pushPanels() in index.ts) drives the Terminal and Chat buttons' aria-pressed, and every toggle — including Jobs, which closes Chat — pushes it. A jobs shell message supplies active count, visibility and staleness; both views replay state after reload.

Polling is serial, every five seconds while visible or active and thirty seconds otherwise after server startup. Active records receive status detail; selected running logs refresh with the poll, completed logs on explicit refresh. Disconnect generations discard late responses; per-job revisions prevent old polls undoing cancellation. Failures retain stale records, and observed active-to-success/failure transitions notify once. Disposal removes listeners/timers without cancelling jobs.

Both chat and raw MCP calls use one timeout selector: ten minutes by default, or a positive run_simulation.wait_seconds plus sixty seconds when longer. Waits beyond Node's timer range return an error before execution. The prompt recommends wait_seconds=0 plus status/log polling; chat approval policy is unchanged.

Validation: test/jobs.test.ts, the timeout cases in test/kratosStartup.test.ts, and node tools/jobs.e2e.mjs (requires a display; use xvfb-run on headless Linux). The Electron scenario uses a deterministic stdio MCP fixture discovered through a temporary uvx wrapper, exercises reconnect/cancel/layout, and is reused by the screenshot generator.

AI chat sidebar (LLM agent + MCP) ​

The Chat toolbar button / Ctrl+Shift+L toggles a right-hand sidebar WebContentsView (lazily created in app/main/windows.ts; layout() shrinks the body views and the terminal panel by CHAT_WIDTH while it's shown). The renderer (app/renderer/chat/, dependency-free, strict CSP) talks to app/main/services/chat/chatService.ts over chat:toHost / chat:toWebview (app/preload/chatPreload.ts); all network and child-process work stays in the main process, and the transcript is replayed on chatReady so hiding/showing the sidebar never loses the conversation.

In streamed deployments, app/main/services/managedConfig.ts overlays operator values in memory above stateStore: KKSS_LLM_PROVIDER, model/base URL, secret-file, project root, restore, theme/zoom, meta-server and cloud client settings are read at startup, shown as managed in the native menu, and blocked from persistence. app/main/services/headless.ts exposes only loopback, bearer-protected activity/shutdown endpoints. The SIGTERM path saves dirty mesh operation recipes and editor buffers (with recovery files where possible), then drains cloud uploads before the normal synchronous store flush. Unknown or stale job state inhibits the container's idle timer.

ChatService runs the agent loop: a provider adapter streams one model turn, tool calls are dispatched, and the loop repeats until the model stops calling tools (or the user hits Stop — dangling tool calls are pruned from the next request by transcript.ts). API providers live under app/main/services/chat/providers/: Anthropic uses @anthropic-ai/sdk, while OpenAI-compatible uses raw fetch + SSE. Subscription providers live under app/main/services/chat/agents/: Codex speaks the official Codex App Server protocol over stdio, and Claude Code uses the official Agent SDK with an in-process MCP server. Both subscription adapters call the same ChatService.executeTool() dispatcher, so approval, denial, lifecycle hooks, dry-run, image, cancellation, and transcript behavior is shared. Subscription sessions persist an opaque provider session id and a hash of the KKSS tool catalog; a mismatch starts a fresh session seeded with transcript data. Historical tool calls are sent as context and are never executed again.

Conversations are durable, and there are many of them. Transcripts live under <userData>/chats/ — one <id>.json per conversation plus a small index.json of the rows the sidebar's history popover lists (title, last-used time, entry count, and which conversation was last active). Each file is its own JsonStore instance, so every write is the same atomic temp+fsync+rename behind a single-writer chain, and only the active conversation is held in memory (TranscriptStore.close() flushes and drops the rest). The split mirrors sessionCore.ts/session.ts: transcriptStoreCore.ts is pure (the versioned blob shapes, parsing that repairs a damaged entry list rather than losing the conversation, title derivation, the caps) and transcriptStore.ts is the filesystem glue. CHAT_STORE_VERSION is ignored wholesale on mismatch, and an index row whose file is gone — or whose blob this build cannot read — is pruned at read time rather than reconciled by a transaction across the two files. Entries are written on append boundaries with a 1 s debounce (never per stream delta: a tool result is capped at RESULT_CHARS = 50 000 characters), plus an undebounced save on each user message and at the end of every turn, and ChatService.flushSync() from will-quit (which lands a still-pending debounced save rather than cancelling it, so quitting inside the debounce window loses nothing). Caps: MAX_CONVERSATIONS = 50 (least-recently-updated evicted, never the active one) and CONVERSATION_ENTRY_CAP = 2000. Stored entries keep the full tool-result text the model was given — toWire()/PREVIEW_CHARS in transcript.ts remains the single truncation point, so the renderer never sees it.

A turn is bound to the conversation it started in. run() captures the active conversation once and every append targets that object; every message it emits goes through a guard that drops it unless that conversation is still on screen. Switching, or deleting the active conversation, therefore aborts the in-flight turn and waits for it to unwind before the switch takes effect — the interrupted turn is recorded as an assistant entry carrying whatever text had streamed, flagged stopped (an append, not a flag set on the previous entry, which would mislabel an already-finished turn when the abort lands on iteration 2+ of the tool loop). Deleting a background conversation never disturbs the running one. newChat archives rather than destroys, so there is nothing to confirm; an untouched conversation is neither written nor listed, so New costs nothing.

Tools come from four stdio MCP servers managed by app/main/services/chat/mcpManager.ts (spawned lazily on first chat use, per-server failure tolerated, tool names namespaced cad__* / mesh__* / kratos__* / pdf__*):

ServerBundle / commandPlacement contract
cad-preview (83 tools)out/cad-runtime/dist/mcp-server.jsbeside the OCCT/Gmsh WASM, so its extensionPath (= dirname/..) resolves to out/cad-runtime
kratos-mcp-server (110 tools)uvx --with "mcp<2" kratos-mcp-server@<version> (or uv tool run)pinned to KRATOS_MCP_VERSION; app-local uv setup and independent retry available in chat

The kratos server is pinned to KRATOS_MCP_VERSION (kratosMcpVersion.ts) — bump that constant to upgrade; the tool/resource/prompt surface is discovered at runtime (listTools), so no other code changes when it grows. Its 0.3.0 knowledge layer also ships MCP resources (worked examples) and prompts (guided setups); McpManager aggregates both (listResources/readResource/listPrompts/ getPrompt, resource URIs owner-mapped, prompt names namespaced). The provider loop only understands tools, so these are surfaced to the chat as four synthetic mcp__* tools (chatTools() = real tools + mcp__list_resources / mcp__read_resource / mcp__list_prompts / mcp__get_prompt).

kratosRuntime.ts probes the app-local uv executable, PATH's uvx, then PATH's uv. Discovery never installs anything. The fixed-purpose installKratosRuntime and retryKratos chat IPC actions reach the shared hub's Kratos-only reconnect operation; they accept no paths, commands, or URLs. Status carries an optional startup phase (probing, installing, preparing) and a failure category, with bounded diagnostics. The assistant sees current availability through the context suffix, while its tool definitions remain fixed for each user turn.

The official uv 0.12.10 installer runs with UV_UNMANAGED_INSTALL under <userData>/runtimes/, verifies the executable, and promotes it to <userData>/runtimes/uv. Temporary files are removed on failure and success. Existing system installations and uv cache/Python locations are preserved. Overlapping operations share one promise; shutdown aborts setup and closes pending MCP transports. Kratos connection and tool discovery each allow five minutes; CAD/mesh retain their existing connection timeout and tool-call timeouts are unchanged.

The mcp<2 constraint is retained for kratos-mcp-server@0.9.0, which still imports mcp.server.fastmcp. The version pin is shared by the launcher, bundled-runtime validation, and offline environment probes; Docker requirements and their hash lock must be updated alongside it. The published 0.9.0 package was verified to initialize and advertise 110 tools, 19 resources, and eight prompts. Linux installer and Electron recovery are verified; Windows and macOS installer commands have unit coverage but remain unverified on real machines. node tools/kratosStartup.e2e.mjs runs the isolated-profile recovery check, also used by npm run docs:screenshots.

The two Node bundles are spawned with Electron's own binary + ELECTRON_RUN_AS_NODE=1 (packaged machines have no system Node), and the full parent environment is always passed to StdioClientTransport — the SDK otherwise strips env to a minimal set, which silently breaks uvx (PATH). The bundles are copied from the submodules' dist/ by esbuild.mjs's copyArtifacts() — which also mirrors the out/meshio/ tree beside out/mcpServer.js so the mesh_convert/mesh_info tools can read/write the extended meshio++ formats (see Extended mesh formats above). The submodule MCP servers are built by their normal build/package scripts on kkss.dev; any required extension-only change is committed on application-downstream.

API keys are entered via Settings ▸ LLM Assistant (showInputBox modals) and stored in the stateStore encrypted with Electron safeStorage (app/main/services/chat/secrets.ts; plaintext fallback when the OS has no keyring). Subscription credentials remain in the official tools' stores. Settings are read per request — no restart needed. stateStore keys: llmProvider, llmModelAnthropic, llmKeyAnthropic, llmModelOpenai, llmKeyOpenai, llmOpenaiBaseUrl, llmModelCodex, llmCodexExecutable, llmModelClaudeCode, llmClaudeCodeExecutable, llmToolApproval.

Tool-call approval ​

app/main/services/chat/toolPolicy.ts decides whether a tool the model asked for runs straight away or has to be approved. It is a pure module (no electron, no node:*, the approval mode passed in rather than read from the stateStore) so test/ drives it directly — test/chatToolPolicy.test.ts.

Classification is a KKSS-side table keyed by the full namespaced name (cad__apply_edit_ops, mesh__mesh_transform), covering 285 non-PDF tools (297 with the reader server's twelve): 83 CAD, 55 mesh, four in-process mcp__* tools, 33 app-owned app__* workflow tools and the 110 tools of kratos-mcp-server, reviewed against the pinned KRATOS_MCP_VERSION (KRATOS_TOOLS_REVIEWED_VERSION in toolPolicy.ts; tools with an optional csv_file, or that always write an image, are write). It is deliberately not derived from MCP's Tool.annotations: the SDK's own type declarations say a client must never make tool-use decisions from a server's annotations, and no cad/mesh tool declares any in the first place. Name-prefix and description heuristics are rejected for the same reason — mesh__problemtype_list looks like a listing and actually executes workspace problemtypes. Anything unlisted is unknown, which always asks; that is what keeps a tool added by a newer server version safe until it is reviewed. gateFor's precedence is never → an always-allow grant → askAlways → read-is-auto → ask. mesh__case_convergence, mesh__case_monitors (mesh 5.5.0) and mesh__case_optimization (mesh 5.11.0) are write: each can write a .csv via outputPath, and monitors also writes the sidecar on set/remove — the same conservative outputPath rule as mesh__mesh_select/mesh__mesh_probe. mesh__case_rom (mesh 5.11.0) is read: bounded lists only, no output path. mesh__case_compare (mesh 5.16.0) is read: receipt plus preparation manifest over left/right only, never writes. CAD's cad__check_mesh_quality and cad__segment_mesh_surface (cad 3.17.0) are read: facts-only over the generated mesh or a triangle soup, neither writes. cad__check_printability (cad 3.18.0) is read for the same reason — a facts-only report plus overlay buffers, nothing written — cad__mesh_cad_surface (cad 3.30.0) is write (it always writes a new mesh at the required outputPath), and cad__check_draft (cad 3.28.0) is read for the same reason with a per-corner draft overlay, and mesh 5.12.0's mesh__plot_run_resolve is read (a disk lookup returning an ownership status), while its mesh__case_export_results is write (it converts result frames under folder, or packs them into an XDMF/PVD).

The gate sits in chatService.ts's tool loop, between appending the toolCall entry (so the user can read the arguments they are approving) and mcp.callTool. Three rules are load-bearing and easy to break:

  • awaitApproval never rejects and always settles. settleTurn() awaits the running turn and five paths reach it (Stop, New chat, selecting another conversation, deleting the active one, will-quit), so a promise that could stay pending would deadlock all five — the abort listener is what keeps the app closable. flushSync() settles it too, before the store latches shut.
  • A denial still appends a toolResult (ok:false). transcript.ts drops a toolCall with no matching result, so a silent denial would vanish from the next request and the model would re-emit the same call, burning one of MAX_ITERATIONS.
  • A pending approval is never persisted. It rides ChatToWebview's state.pendingApproval and is replayed on chatReady, so a renderer reload resumes a blocked turn rather than stranding it — while a hard crash cannot replay live-looking buttons wired to a promise that no longer exists. The decision is persisted, as an optional approval field on the toolCall entry, which needed no CHAT_STORE_VERSION bump (a bump discards every stored conversation; an optional field degrades correctly in both directions).

"Always allow in this conversation" lives in a service-level Map<conversationId, Set<string>>, not on LiveConversation: store.close() drops that object on every conversation switch, so a grant there would expire on a round trip through the history popover. Project-folder grant. allowScoped records the tool name in a parallel scopedAllow map (same lifetime). gateFor gets an optional scoped: {granted, inside()}; the inside check is injected (toolPaths.ts's callInsideProject, which reads node:fs/node:path) so toolPolicy.ts stays pure. The prompt's scopable/scopeRoot fields are computed by main and mean this call is a scopable write with all paths inside projectRoot.explicit() — the inferred root is never used. SCOPED_TOOL_PATH_ARGS lists path argument names per tool (dotted for nested, arrays expanded); any unlisted tool, relative path, missing declared path or unknown path-looking argument returns null and asks as before. A decision of allowScoped for a prompt that did not offer it is treated as a one-off allow. test/toolPaths.test.ts pins which write tools are deliberately unscoped. New wire messages: approveTool (renderer → main, correlated by callId), approvalRequest and approvalResolved.

unclassifiedTools() is the submodule-bump seam: ChatService logs the unclassified names once per process, and test/chatToolPolicy.test.ts pins the exact policy key set, including the Kratos rows, and fails when KRATOS_MCP_VERSION moves without KRATOS_TOOLS_REVIEWED_VERSION and the rows being re-reviewed (download the new wheel, read its @mcp.tool functions, classify each). Compare it with live tools/list during a bump: the snapshot alone cannot discover new upstream tools.

Validate (dry run) ​

Four cad tools take a dryRun parameter (cad__apply_edit_ops, cad__run_parametric_script, cad__run_saved_script, cad__import_svg), listed in toolPolicy.ts's DRY_RUN_PARAM. When the blocked call is one of them the prompt grows a fourth button, Validate (dry run), which re-issues it with that flag set so the user can answer the prompt with a report in front of them.

It is deliberately not a fourth decision. dryRunTool (renderer → main, callId-correlated like approveTool) leaves the gate open, and the report is pushed back as dryRunResult and never appended to the transcript — so the model is never handed a result for a call that did not happen, which is the semantics problem that kept this unbuilt, and no contextSuffix() or system-prompt change was needed. dryRunArgs() in toolPolicy.ts is the single decision point: it rewrites the arguments without mutating them, and returns null (which is also what hides the button) for a tool with no row, unparseable or non-object arguments, or a call the model already marked dryRun: true.

Because the handler runs outside run(), the turn's conversation and abort signal are carried on the pendingApproval record and re-read when the call returns — a Stop, a conversation switch or a delete drops the report rather than painting it into whatever is on screen. awaitApproval's finish() nulls that record, which is what makes an in-flight dry run stale on Deny, Allow, abort and flushSync() alike. Re-entrancy is guarded on main rather than by disabling the button, since a state replay rebuilds the prompt with a fresh one. A completed report rides pendingApproval.dryRunPreview so a reload keeps it.

The wording is narrow on purpose: cad gates its OCCT replay on the same flag it gates its writes on, so the report says which operations parse and are legal — not what the geometry would become.

Images in tool results ​

mcpManager.extractImages() forwards an MCP tool result's {type:"image"} blocks to the sidebar, which is how cad__render_snapshot and cad__compare_models become visible. flattenContent() is untouched, so the model still reads its [image content] placeholder and the two views cannot drift apart (a test in test/chatMcpRouting.test.ts pins the pairing).

They are session-only. The transcript store holds the full tool-result text the model was given, and the model is not given the bytes — so ChatEntry, parseEntry and CHAT_STORE_VERSION are unchanged, and no conversation file carries megabytes of base64 through its whole-file atomic rewrite. They also never ride a ChatWireEntry: they travel as their own toolImages message, sent after the entry and replayed after state, because sendState() fires on far more than a reload and webContents.send structured-clones synchronously on the main thread. The cache is one Map<callId, ChatImage[]> for the active conversation, cleared in switchTo(), so IMAGE_BUDGET_BYTES is the global bound by construction (oldest-first eviction).

Caps are set for the renderer's benefit, not the wire's: a byte cap bounds the transfer but not the decoded bitmap, so the per-image cap is small, the message carries live (a replay never force-opens a chip), and the <img> is created lazily when the chip is actually expanded. SVG is excluded from the mime allow-list and the base64 is shape-validated — it is interpolated into a data: URL by a page with no 'unsafe-inline'. app/renderer/chat/index.html's CSP already allowed img-src kkss: data:, so it needed no change.

Attachments (images and PDFs) ​

A user message can carry files: ChatEntry.user.attachments?: ChatAttachment[] ({id, kind: "image"|"pdf", mimeType, name?, dataBase64}) plus attachmentsEvicted?. Everything about them that is a decision lives in the pure services/chat/chatAttachments.ts: the mime allow-list (SVG excluded), the base64 and %PDF- magic-byte checks, the caps (MAX_IMAGE_B64 2 MiB and MAX_PDF_B64 7 MiB of base64, four images and three PDFs per message), validateAttachments (all-or-nothing — the renderer is untrusted on every send), parseStoredAttachment, enforceAttachmentBudget, and attachmentSupport(provider, kind, imageOptIn), the single decision point for who may receive what.

The contrast with tool-result images (above) is deliberate. Those are display-only and session-only because the model never receives the bytes; a user attachment is the model's input, so it is stored in the conversation file, and every limit is therefore a hard cap with a defined eviction: CONVERSATION_ATTACHMENT_BUDGET (16 MiB, larger than any single message can be) evicts the oldest message's files whole — never the newest, so a send cannot evict itself — and marks the bubble with attachmentsEvicted. The optional fields need no CHAT_STORE_VERSION bump (the approval/usage precedent); parseEntry drops one damaged file, never the message.

toWire() must strip the bytes: user entries used to pass through by identity, and a stored attachment would otherwise ride every state and entry message. The wire entry carries ChatAttachmentRefs; image bytes arrive on their own userImages message, sent after the entry and replayed after state from the stored entries (unlike toolImages, which replays a session cache). The two converters: Anthropic gets image/document blocks ahead of the text (and never an empty text block — an attachment-only message has none); the OpenAI converter keeps content a plain string unless an image is present, so text-only requests stay byte-identical, and never emits a PDF (chat/completions has no portable form).

Refusals are codes, not sentences (subscription / gatewayOptIn / pdfNeedsMcp), so the renderer maps them through t(). The composer's attach state carries them per kind; run() re-checks and answers a refused send with sendRefused before appending anything, handing the text back (the sidebar clears its draft on submit). Three further guards share attachmentSupport: requestEntries() replaces files the current provider cannot take with a text note on the request copy (a mid-conversation provider switch must not send an image to a text-only gateway, which would surface as an opaque error); seedPrompt strips them for the subscription runtimes (no base64 in a Codex/Claude Code prompt); and a context overflow with nothing left for compaction to clear retries once with only the newest message's files. The opt-in llmImageInput setting is a registry row read per message.

The renderer (app/renderer/chat/chat.ts) needs no preload API: it reads Files from paste, drop (dragover/drop are always cancelled, or an unhandled file drop navigates the view away) and a hidden file input. Images are re-encoded on a canvas to ≤1568 px WebP before they are staged; PDFs are base64 as-is and refused above the cap. Thumbnails are createElement + .src = data:… — the CSP already allowed img-src data: and has no 'unsafe-inline'.

The PDF reader (pdf server) ​

pdf-insight-mcp is the fourth stdio server, launched uvx --with "mcp<2" pdf-insight-mcp@<PDF_MCP_VERSION> and pinned in services/chat/pdfMcpVersion.ts. It was chosen after a real uvx handshake: PyPI-published, MIT, all twelve tools read-only (each takes a file_path and returns text/JSON), where mcp-pdf-reader has no licence and a separate FastMCP dependency, and the other candidate is GitHub-only. It depends on PyMuPDF (AGPL-3.0); it is launched as a separate process and never bundled, which is compatible with KKSS's AGPL-3.0-or-later. The twelve pdf__* rows are in toolPolicy.ts (PDF_TOOLS_REVIEWED_VERSION must move with the pin; test/chatToolPolicy.test.ts ties them); there is deliberately no toolPaths.ts row since a read never needs a scope grant. Bumping the pin: re-read the new tool list, update the rows, move both constants.

It shares Kratos's uv recovery rather than adding a second banner: McpManager treats kratos and pdf as UV_SERVERS (same runtime discovery, five-minute first-connect timeout), only Kratos owns installing uv, and retryKratos()/restartKratos() fire-and-forget a retry of a not-ready PDF server afterwards (revivePdf). A failing PDF server never blocks CAD, mesh or Kratos. When KKSS_KRATOS_PYTHON points at the Docker image's bundled Kratos launcher, that launcher is useless for another package, so the PDF spec keeps its plain uvx (it needs the network; the offline -kratos image does not bundle it). The e2e uvx shim (tools/fixtures/jobs-server.mjs) answers the PDF launch as a distinct "PDF fixture" server.

Token, cost and context ​

TurnResult.usage carries what a turn cost, and is optional: absent means the provider reported nothing, which must stay distinguishable from "cost nothing". Getting one comparable number out of two APIs takes work on both sides — Anthropic bills cache_read_input_tokens and cache_creation_input_tokens in addition to input_tokens (so the context figure is the sum of all three), while OpenAI counts cached tokens inside prompt_tokens (so parseUsageChunk subtracts them back out). TurnUsage.input means uncached input on both.

The OpenAI-compatible path needs a request change too: usage is only sent when stream_options: {include_usage: true} is asked for, and it arrives in a final chunk with an empty choices array — which is why it is parsed before the delta guard that skips choice-less chunks. A stricter gateway may reject the unknown field outright, so a 400 mentioning stream_options retries once without it and accepts no usage, mirroring the Anthropic conservative retry.

app/main/services/chat/modelInfo.ts holds context windows and per-1M prices, in the same deliberate-table mould as toolPolicy.ts: a row is a reviewed edit and test/chatModelInfo.test.ts pins the key set. An unrecognised model resolves to null, and the sidebar then shows raw token counts with no cost and no context percentage — the normal case for the OpenAI-compatible provider, which points at arbitrary gateways whose pricing KKSS cannot know — unless the user filled the five llm.custom* registry rows (Settings ▸ LLM Assistant). resolvePricing(model, custom) applies those only when modelInfo() is null (a reviewed row always wins), prices only when both input and output rates exist (unset cache rates fall back to the input rate), lets the context window stand alone, and flags the result userSupplied → ChatUsage.userSupplied → a tooltip line. parseCustomPricing drops non-finite, negative or non-integer-window values from a hand-edited state.json. The proactive compaction trigger uses the same resolver, so a user-entered window enables it. Nothing new is stored on a conversation (cost is derived at send time), so there is no CHAT_STORE_VERSION change. Cache rates are stored per row rather than derived from the input price, because the usual 0.1x/1.25x relationship has exceptions.

Cumulative usage is an optional usage field on StoredConversation, needing no CHAT_STORE_VERSION bump (the approval precedent), and is persisted rather than session-only because a lifetime cost that reset on restart would be worse than none. lastInput is replaced per turn rather than summed — "how full is the window" is a different question from "what has this cost". Main resolves the price and window before sending, so the renderer never carries the pricing table.

Two new ChatErrorKinds, context and rateLimit, replace the bare other banner these used to get. Any new kind must also be added to ERROR_KINDS in transcriptStoreCore.ts, or a stored error of that kind is silently rewritten to other on read. A context overflow also suppresses the Anthropic conservative retry: it is a 400, but lowering max_tokens cannot shorten an over-long prompt, so retrying only cost a round trip and reported the wrong error.

Model picker and thinking level ​

The composer strip (#composer-options in app/renderer/chat/) is driven by one main→renderer message, composer (ChatComposerState: provider, model, default, suggestions, managed flag, thinking level, whether the provider supports it), sent after state and on every llm* stateStore.onDidChange — the Settings page, the native menu and the picker all write the same keys, so none can leave the strip (or the header's provider label, which used to go stale) out of date. The renderer posts setModel / setThinking; main validates (string, control characters stripped, 200-char cap, level in THINKING_LEVELS), refuses a managed model key, and writes through stateStore.update — the picker never has state of its own. Model and thinking are global, not per-conversation: run() reads settings once per turn, so a change applies from the next message and a turn in flight is never altered. Suggestions come from modelInfo.ts's knownModels(provider), so the picker cannot offer a model the cost readout cannot price; every id stays reachable through Other….

StreamTurnOptions.thinking (auto|off|low|high) and .maxOutputTokens are optional, and absent for auto/unset, so default requests are byte-identical to before the control existed. Anthropic (buildAnthropicParams, pure): auto is adaptive thinking; low/high add output_config.effort; off omits thinking (never {type:"disabled"}, a 400 on newer models); the conservative retry drops thinking, output_config and cache_control together and keeps its fixed 4096 max_tokens. OpenAI-compatible (buildOpenAiBody, pure): reasoning_effort (off→"none") and max_tokens ride the same askForExtras flag as stream_options, and an error naming any of them retries once without all of them. The subscription runtimes get neither (thinkingSupported: false disables the control). The strip's glyphs (providerAnthropic/providerOpenai/providerOpencode/providerCodex/providerClaudeCode/thinkBrain in app/renderer/glyphs.ts) are KKSS's own line drawings, not vendor logos.

Prompt caching ​

The Anthropic request carries one cache_control breakpoint, on the system block. The render order is tools → system → messages, so it covers the tool definitions too — which is the point, since the whole MCP toolset is re-sent on each of up to MAX_ITERATIONS iterations. This is what SYSTEM_PROMPT's byte-stability and contextSuffix()'s "volatile context rides the newest user message" rule were always for; before the breakpoint existed the discipline was paid for and never collected on. Consequently changing the system prompt, or moving volatile context into it, now has a measurable cost, and cache_read_input_tokens reading zero across repeated turns means something is invalidating the prefix. The conservative retry drops cache_control along with thinking.

ChatService.run() snapshots the available tool definitions once per user turn and uses that list for every model request, including context-overflow retries and the unclassified-tool check. It does not wait for all MCP servers to connect: newly connected servers contribute tools on the next user turn. This keeps asynchronous startup from changing the tools + system cache prefix between iterations of the first turn. The system prompt is unchanged.

Transcript compaction ​

A long conversation eventually stops fitting in the model's context window. app/main/services/chat/compaction.ts handles that by clearing the text of older tool results from requests — not by summarizing. It runs on the copy requestEntries() builds, so convo.entries, the sidebar and <id>.json all keep the full text; the sidebar still shows every result, and a line under the header discloses that the model is no longer being sent them.

Clearing works because it is structurally invisible: groupTurns matches a result to its call by callId alone and the "answered" filter is a Map.has, so replacing a toolResult's text leaves the message list role-for-role identical. That removes the cut-point rule, the orphan-tool_result hazard and the model call summarizing would have needed. It only holds for tool results — blanking an assistant entry can delete a whole message. Only ok === true results are cleared: a denied call's text is the "do not retry" instruction the approval gate depends on.

The boundary is a count (StoredConversation.compactedResults), not a reference to a particular call. An id anchor fails twice — it vanishes when capEntries trims the front, and tool-call ids are not unique on the OpenAI-compatible path, where a gateway that streams no ids gets synthetic call_<index> values that repeat every iteration. A count has no identity to lose, and when it no longer matches the entries it simply clears more. The invariant is that a stale boundary must never clear less than before. It is persisted (with a parse line in parseConversation, which rebuilds field by field) so it survives a restart and a conversation switch, and it only ever grows.

Two triggers. Proactive: after the per-iteration usage fold, when lastInput passes 70% of a known context window — which with 1M windows rarely fires. Reactive: a context provider error advances the boundary and retries, and this is the path that does the real work, and the only one that helps a model whose window KKSS does not know. The retry wraps only the streamTurn call, never the loop body, because assistantStart opens a fresh assistant bubble in the renderer; it retries once by construction, since the second call has no catch.

What it cannot fix: the tool schemas re-sent every iteration, and toolCall.argsJson, which nothing truncates and which cannot be blanked because the wire formats need real JSON. And "tool results dominate" is an empirical claim, not a bound — one maximal assistant message is larger than one maximal tool result.

Two latent defects were fixed alongside it: capEntries snapped its cut forward to the next user entry instead of slicing mid-turn, and toAnthropicMessages's first-message trim now strips leading tool_result blocks rather than dropping the message (same-role merging means that first user message routinely carries the orphan and the first real user text together).

User- and project-configured MCP servers (the manager) ​

The sidebar's MCP manager (app/renderer/chat/mcpManager.ts, view-model in mcpManagerView.ts; user docs in Assistant extensions) adds servers beyond the four built-ins without touching the approval gate. The pieces, from pure to effectful:

  • chat/userServers.ts (pure, unit-tested) — id rules (^[a-z][a-z0-9-]{0,31}$; cad/mesh/kratos/pdf/app/mcp reserved, because classification is keyed by the full name and a user server called cad would inherit cad__list_layers' read row), definition parsing (parseServerList, size-capped, errors reported rather than thrown), the URL rule (http(s) only, no embedded credentials, a bearer token never over cleartext off the loopback), launchFingerprint, the mark store and makeClassifier. Marks are { [id]: { fingerprint, tools } } under assistantMcpToolMarks, bound to the launch fingerprint so an edited command/URL drops them; a project file can never carry one.
  • chat/folderTrustCore.ts (pure) — the folder-trust record (assistantFolderTrust, { [realpath(explicit root)]: { hash, at } }). The hash covers every file assistantFiles.readAssistantTree reads under .kkss/assistant/ — mcp.json, instructions.md, skills/<name>/SKILL.md, prompts/<name>.md — so one trust decision covers MCP servers, instructions, skills and templates, and an edit or a new file asks again (a folder holding only mcp.json hashes exactly as it did before the other kinds existed). readAssistantTree(base, confine) is the one reader: for a project it demands regular files inside the root, no symlinked directories, ≤ 64 KB each and ≤ 64 skills/templates, and fails the whole read on anything unsafe (state invalid) rather than loading around it; the user's own directory (confine: null) may use symlinks. States: none | untrusted | trusted | changed (+ invalid).
  • chat/userServerStore.ts — the glue, Electron-free behind StoreDeps (state, secrets, projectRoot.explicit(), host env). It reads the project file defensively (regular file, realpath inside the root, ≤ 64 KB), turns definitions into ServerSpecs and ChatMcpServerRows, and owns every mutation (save, remove, setEnabled, setMark, trustProject, revokeProject). Env values and tokens go through chat/secrets.ts under assistantMcp.<id>.env.<NAME> / .token and are resolved lazily at connect time (ServerSpec.resolveEnv / http.token), so no status, row or IPC message ever carries one. A project spec carries preflight(), which re-reads the folder and re-verifies trust immediately before every spawn, so a restart cannot launch a definition edited after it was trusted.
  • McpManager — ServerKey is now a string; ServerSpec gains source, http, resolveEnv, blocked (disabled | untrusted), preflight and revision. setUserServers(specs) reconciles only the non-built-in servers (an unchanged revision keeps its connection, an edit reconnects, a removal closes; before start() it stays lazy), restartServer(key) generalises restartKratos, and toolList(key) feeds the Tools view. Stdio servers get the SDK's default environment (PATH, HOME…) plus the named variables — never the app's whole environment, unlike the built-ins; HTTP servers use StreamableHTTPClientTransport with redirect: "error". A user tool whose namespaced name a provider would reject (^[a-zA-Z0-9_-]{1,64}$) is left out and counted in skippedTools. Built-in statuses stay byte-identical (no source/transport field). Resources and prompts of user servers are excluded from the mcp__* tools and the meta server: those tools are classified read, so aggregating them would be an un-gated read path into an arbitrary server.
  • The gate. ChatService.executeTool builds its classifier from UserServerStore.classifier() and passes it to gateFor (GateOptions.classify, default classifyTool), so built-in names still resolve through the reviewed table and a user tool is unknown (⇒ ask) unless marked. toolPolicy.ts's built-in rows are untouched (test/chatToolPolicy.test.ts's pin moved only for app__skill_load); a user tool is never offered the project-folder grant (no SCOPED_TOOL_PATH_ARGS row) and never counted by reportUnclassified. The Claude CLI and Codex runtimes need no change: they expose whatever chatTools() returns and keep their own plugin/MCP systems off. Meta server: metaToolAllowed already treats an unclassified name as allowed only under all, or under readListed when listed in metaServerAllowedTools — exactly the contract for user tools; marks deliberately do not widen it (test/metaServer.test.ts).
  • IPC. ChatToHost gained mcpRefresh/mcpSaveServer/mcpRemoveServer/mcpSetEnabled/mcpRestart/mcpToolsRequest/mcpSetMark/mcpTrustProject/mcpRevokeProject/mcpReloadProject; ChatToWebview gained mcpManager (rows + project state + managed, replayed on chatReady, sent before an error), mcpTools, mcpSaved, mcpError and mcpOpen. They are handled in ChatService.handleMcp, behind the same sender check as every chat message, and every payload is revalidated there. A managed assistantMcp* key makes every edit answer mcpError: managed.

Tests: test/userServers.test.ts, test/userServerStore.test.ts (temp-dir project folders, symlink escape, trust lifecycle, secrets never in state or rows), test/mcpUserServers.test.ts (mocked stdio/HTTP transports, reconciliation, resources/prompts exclusion), the user-server block of test/chatService.test.ts, the allow-list case in test/metaServer.test.ts, test/mcpManagerView.test.ts, and the real-app scenario tools/e2e/chat-mcp-manager.mjs (it also supplies chat-mcp-manager.png to tools/screenshots.mjs).

Standing instructions, skills and prompt templates ​

Three text-only definitions (user docs: Assistant extensions) that reach the model through paths that already existed — no new tool source, no new gate:

  • chat/assistantDefs.ts (pure) parses {relative name: text} maps: instructions.md (cleaned of control characters, capped at MAX_INSTRUCTIONS_CHARS = 6000 with a truncated flag), skills/<name>/SKILL.md (a --- header read by a deliberately tiny key: value parser — no YAML engine, so no tags, anchors or expansion; description required and one line, name optional but must equal the folder) and prompts/<name>.md. mergeDefinitions lets the user's win a name collision (the MCP-server-id rule). definitionsSuffix renders the context block; skillCatalog bounds it (MAX_CATALOG_CHARS) and counts what it leaves out; expandTemplate implements /name [args] ($ARGUMENTS, // escape, only a defined name expands so paths pass through, MAX_EXPANDED_CHARS).
  • chat/assistantDefsStore.ts — the glue (injected userDir, enabled(), trustedProjectFiles()): snapshot() reads <userData>/assistant and, via UserServerStore.trustedProjectFiles(), the project's files only while the folder is trusted right now (a fresh read verified against the recorded hash, which also refreshes the manager's cached state). tools() is the one app__skill_load tool, registered next to the workflow tools in index.ts (registerAppTools([...workflows.tools(), ...assistantDefs.tools()])); it returns a skill's text and runs nothing, and a project skill is looked up in a fresh trust-verified snapshot, so a file edited after trust cannot be loaded.
  • ChatService: run() expands a leading template before the user entry is appended, so the transcript — and every later request, compaction pass and stored <id>.json — holds exactly what the model was given; an over-long expansion is refused through sendRefused. It takes one snapshot().suffix per user turn (turnDefsSuffix) and contextSuffix() appends it after the workspace line, so the text is identical across every iteration and compaction retry. It rides the request copy only — never SYSTEM_PROMPT, whose bytes are the prompt-cache prefix — and the subscription runtimes get it by the same path (their own skill/instruction discovery stays off). The system prompt gained exactly one sentence (the app__skill_load capability); app__skill_load is a read row in toolPolicy.ts (297 rows). templateHints answers the composer's suggestion strip (length-capped, only for text matching ^/[a-z0-9-]*$); assistantDefs carries the panel's summary and the estimated tokens, which the usage readout's tooltip repeats.
  • Setting: llmAssistantDefs (default on; off ⇒ snapshot() is empty, nothing is expanded, app__skill_load says so). The folder button opens <userData>/assistant after creating it.

Tests: test/assistantDefs.test.ts (parsing, merging, suffix, catalog budget, expansion rules), test/assistantDefsStore.test.ts (temp-dir trees, symlink/size/count refusals, trust gating, edit-after-trust, app__skill_load), the standing-instructions block of test/chatService.test.ts (suffix on the request copy and not the system prompt, no accumulation in the stored transcript, expanded text stored, subscription path, byte-stable system prompt) and the real-app scenario tools/e2e/chat-defs.mjs (which also supplies chat-assistant-defs.png to tools/screenshots.mjs).

Kraty ​

An optional animated face (roadmap item 10, shipped with the next release). Presentation only: app/renderer/chat/kratyCore.ts is a pure reducer from the host messages the sidebar already receives (busy, assistantStart/Delta/Done, toolCall/toolResult entries, approvalRequest/Resolved, error, and a state replay) to a KratyState, with expressions and bubble i18n keys as data tables; kraty.ts draws one inline SVG and flips data-* attributes that chat.css keys on (the page's CSP forbids style=). The setting llmKraty (registry row llm.kraty, default off) reaches the renderer as ChatComposerState.kraty, repainted on any llm* change. Nothing model-visible changes: no system-prompt, tool, policy or context-suffix edit. Precedence is needs-you > working > writing > waiting > error > finished > idle; finished times out to idle after four seconds, look-around timers pause while document.hidden, and prefers-reduced-motion drops the idle animation. Tests: test/kraty.test.ts; acceptance and the documentation capture: tools/e2e/chat-kraty.mjs.

Lifecycle hooks ​

User-defined commands around the assistant's tool calls and messages (roadmap item 10, shipped in 2.23.0). The user-facing rules are in Assistant extensions; this section is the implementation and the invariants it must keep.

  • app/main/services/chat/hooks.ts is pure: parseHooks (schema, caps — 32 hooks per file, 100–60 000 ms timeout, an entry that fails validation is dropped with a message rather than loaded half-defined), matchesTool (an anchored * glob on the full namespaced name), buildPayload (the JSON written to stdin, with result.text capped at 8 KB), and the decision table — judge (one run → pass / deny / ask / failed), stops and fold (many runs → one HookOutcome). Exit 0 is no opinion, 2 denies (PreToolUse, UserPromptSubmit only), 3 asks (PreToolUse only); a failure is a deny only for PreToolUse. There is deliberately no "allow" outcome anywhere in the type.
  • hookRunner.ts runs one child (HookExecutor is the test seam): spawn with detached on POSIX so the whole group is killed (taskkill /T /F on Windows), a timeout timer, an abort listener, output capped at 8 KB per stream, and a stdin error handler because a hook that never reads its input closes the pipe. It resolves with the exit code instead of rejecting (the code is the answer) and keeps a live-child set so killAll() works without a signal.
  • hooksStore.ts's HookEngine is the AssistantDefsStore twin: snapshot() reads readAssistantTree(userDir, null) plus UserServerStore.trustedProjectFiles() (a fresh, hash-verified read, so an edit after trust stops a project hook at once and fires onChange), run() executes the matching hooks in order with the SDK's minimal environment (getDefaultEnvironment() plus KKSS_HOOK_EVENT/KKSS_PROJECT_ROOT, injected from index.ts — never process.env), and summary() feeds the panel (ChatAssistantDefs.hooks/hooksEnabled/hookErrors). run() rejects only with the caller's abort. When the user's assistant folder cannot be read and a hooks.json exists (the shared reader fails the whole folder on one unsafe file) PreToolUse is denied rather than run without the user's policy.
  • assistantFiles.readAssistantTree lists hooks.json beside mcp.json, which is the whole of the trust integration: the folder-trust hash covers every file that function returns, so adding a definition kind there is enough for Trust / Changed / Revoke to apply to it.
  • In ChatService. executeTool runs PreToolUse after the toolCall entry is appended and before gateFor. A deny sets the optional hook field on the entry (setHook, the setApproval twin, pushed as hookNote), appends the toolResult ok:false the gate rule requires, and returns; an ask is OR-ed with the gate's answer, never inside gateFor, so toolPolicy.ts and its key-set pin are untouched, and the prompt carries access: "hook" (for a read tool) plus hook: {name, reason}. PostToolUse runs after the tool returned; an opted-in note is appended to the result through withHookNotes before the toolResult entry is written, so the sidebar, the transcript and the model all see the same labelled text. UserPromptSubmit runs in run() after template expansion and before the user entry is appended; because run() is synchronous up to busy = true, a promptHooking flag joins the busy guard and settleTurn(), and the turn's AbortController is created before the hook so Stop and conversation switches can interrupt it. A refusal is sendRefused with reason hook, handing the draft back. TurnEnd is fire-and-forget from the finally (outcome done/error/stopped). flushSync() and dispose() call killAll(): flushSync runs at will-quit before the abort listener, so a hook child could otherwise outlive the app. The vendor CLIs' own hooks stay off, so the subscription runtimes reach hooks only through executeTool.
  • Persistence. hook?: {name, decision: "denied" | "ask", reason?} is an optional toolCall field, parsed by transcriptStoreCore.ts's parseHookNote (a malformed value costs the note, never the entry) — no CHAT_STORE_VERSION bump, the approval precedent. The hook list and its parse errors travel on the existing assistantDefs message.
  • Meta server. buildMetaServer(hub, version, policy, hooks?) runs PreToolUse after metaToolAllowed and before callToolRaw, and PostToolUse after; MetaHooks is the one-method slice of the engine. There is nobody to prompt, so an ask is a deny there. Home's and Jobs' callToolRaw calls and dryRunTool do not fire hooks. The setting is Settings ▸ LLM Assistant ▸ Lifecycle Hooks (llm.hooks, llmHooks, default on).
  • Tests. test/hooks.test.ts (parsing, glob, decision table), test/hookRunner.test.ts (real node -e children: exit codes, stdin, timeout, abort, killAll, environment, output cap), test/hooksStore.test.ts (real files, real folder trust, ordering and short-circuit), the "lifecycle hooks" block of test/chatService.test.ts, test/metaServer.test.ts, and the transcript round-trip in test/chatTranscriptStore.test.ts.

Meta MCP server (expose the toolset over HTTP) ​

The same aggregated toolset can be re-exposed as a single MCP server so an external LLM client (Claude Desktop, another agent) drives KKSS — the inverse of the sidebar (which makes KKSS an MCP client). One McpManager is shared between both front-ends via McpHub (app/main/services/chat/mcpHub.ts), constructed once in index.ts and disposed on will-quit; whichever of {chat opened, external client connected} happens first spawns the four children, the other reuses them — never a double spawn.

app/main/services/metaServer/ holds the server: buildServer.ts wires McpManager behind the low-level MCP Server (raw JSON-Schema tools forwarded verbatim via callToolRaw, resources & prompts re-exposed natively), and metaServer.ts (MetaMcpServer) runs a bare http.createServer bound to 127.0.0.1 with the SDK's StreamableHTTPServerTransport (stateful sessions keyed by Mcp-Session-Id; late server readiness emits list_changed). It is off by default, requires an Authorization: Bearer <token> (generated on first enable, safeStorage-encrypted like the API keys), and validates the Host header — these tools touch the filesystem and run simulations. The SDK server subpaths bundle into out/main.js; nothing new ships in node_modules. Enable it and copy the http://127.0.0.1:<port>/mcp address + token from Settings ▸ MCP Server. stateStore keys: metaServerEnabled, metaServerPort (default 7391), metaServerAccess, metaServerAllowedTools; secret: metaServerToken. Access policy. An external client has no user to prompt, so the approval gate above cannot apply; instead metaServer/accessPolicy.ts (pure, reusing classifyTool) decides allow/refuse per call from Settings ▸ MCP Server ▸ Allowed Tools: readOnly (default — only read rows), readListed (reads plus the names in Allowed Write Tools) or all. buildServer.ts reads the policy on every request (injected as MetaServerDeps.access()), filters tools/list to the permitted set, and answers a refused tools/call with an MCP error result naming the setting — never a silent no-op. A setting change applies to live sessions and sends tools/list_changed (MetaMcpServer.refreshTools). An unclassified tool is refused unless listed. The one-time upgrade decision (metaAccessMigration, guarded by the metaServerAccessMigrated flag because choosing the default deletes the stored key) keeps an install that already had the server configured on all; a fresh install starts read-only. KKSS_META_ACCESS locks the choice for operators. Path scoping is not applied here.

Cloud staging layer ​

app/main/services/cloud/ opens a Google Drive / Dropbox / OneDrive file by downloading it into a local staging cache and handing that local path to openFile(). Nothing below the staging layer knows a document is remote: routing (router.ts's longest-suffix rule), allowRoot(), both hosts' fs calls and the submodules' own MCP servers all keep working unmodified. That is the only shape that was available — every consumer of an opened path assumes a real file already on disk, and the zero-modification invariant rules out teaching the submodules' node:fs calls to speak a provider API.

The pure/glue split is the usual one, so test/ can drive the interesting parts without Electron: cloudCore.ts (types, CLOUD_KEYS), cachePathCore.ts (path derivation, sidecar rules — including the mesh <stem>.kratosview.json view-layers sidecar alongside the run sidecar), manifestCore.ts, conflictCore.ts, oauthCore.ts, uploadChunkCore.ts and the three providerCore/*.ts parsers are pure; oauth.ts, providers/*.ts, stagingCache.ts, cloudSync.ts and the cloudService.ts façade are the glue. Tests: cloudCachePath, cloudManifest, cloudConflict, cloudOauth, cloudProviders, cloudUploadChunks, cloudCompanions and cloudSync — the last drives the whole write-back engine against a real temp directory, real chokidar and a fake provider.

Decisions worth knowing before changing any of it:

  • Layout. <userData>/cloud-cache/<provider>/<sha256(account,item)[0:16]>/<original filename>. Deterministic so sidecars survive across sessions, opaque so a Dropbox path or Drive id cannot escape the cache root, and the filename is preserved byte-for-byte because routing is extension-driven (case.post.msh must not become case.msh). One directory holds exactly one document plus its sidecars and declared companions — that is what makes cad's ${modelPath}.parts.json siblings land correctly with no cadHost.ts change.
  • allowRoot() is untouched. CadHost.openPath already allow-lists path.dirname(fsPath), which for a staged file is its staging directory. Allow-listing cloud-cache/ itself would make every cached document from every account fetchable by any webview — the mistake the project-folder invariant exists to avoid.
  • Write-back is watcher-driven, not host-driven, and that is forced. mesh/src/meshExport.ts's saveMesh() overwrites the document in place with a bare fs.promises.writeFile inside the submodule and never reports the path back, so a save can be observed but never intercepted. One chokidar watch per staging directory (the bare * pattern added to services/watcher.ts) covers cad's ten sidecar writers, mesh's in-place save, an export aimed back into the directory and EditorService at once. It is depth: 0 unless the format declares companions.
  • Companions are an explicit allow-list. cachePathCore.companionsFor names them: XDMF/XMF → sibling <stem>.h5; .foam → the constant/polyMesh/ tree (at most two levels below it, MAX_COMPANION_FILES = 200). Because a tree can now sit beside the document, "every other file is a sidecar" is no longer true — isCompanionPath (traversal, dotfiles, .tmp/.download at any segment) decides what a nested path may upload. companions.ts is the transfer half: downloadCompanions (called from StagingCache.stage; any failure fails the open, since a .foam without its mesh is an empty case) and RemoteFolders (per-pass cache over the providers' idempotent, race-tolerant ensureFolder; a file where a folder is needed is a conflict, never replaced). Uploads follow the document's conflict rule per file, with baselines in the optional CloudEntry.companions (no manifest version bump): absent remotely → create; remote still at the stored revision → overwrite; otherwise (including no baseline) → keep local, upload a (conflict …) copy beside it, adopt the remote revision. Companion bytes count toward the cache budget. A pass remembers which names landed, so a retry after a partial failure never re-uploads a file against its own stale baseline. Local deletes are not propagated.
  • Revisions. Drive headRevisionId, Dropbox rev, Graph cTag — never eTag, which also moves on metadata-only edits and would manufacture a conflict copy every time OneDrive touched the file for its own reasons. The wire-level precondition is carried separately (precondition on CloudFile) because Graph's if-match accepts only eTag. Only Dropbox enforces the conditional server-side; Drive has no conditional media overwrite at all, so its guard is check-then-upload with a residual race, which its module header states plainly.
  • Conflicts keep the local file untouched and upload it beside the remote one, named with meshExtname's longest-suffix split so case.post.msh stays routable. The remote's current revision is then adopted as the new baseline, or every subsequent tick would conflict forever.
  • cad-preview-macros.json and cad-preview-mesh-presets.json are never synced — both are per-folder libraries while the cache is per-file, so uploading either would let two models from one remote folder overwrite each other's.
  • Quit. will-quit cannot await an HTTPS upload or a shell's exit, so before-quit calls preventDefault() once (guarded by a flag, so it can never loop) for whichever is pending, drains for up to 10 s, then re-quits. The hold waits until no dirty prompt can still cancel the quit, so a cancelled quit neither drains uploads early nor shuts the shell down. Anything still unsent stays dirty: true in the manifest and is reported on the next launch. will-quit only calls cloud.flushSync(), the manifest JsonStore's last synchronous write.
  • OAuth is bring-your-own-client. No KKSS client id is baked in. oauth.ts reuses metaServer.ts's listen/error/Host-check shape for a one-shot 127.0.0.1 listener, with PKCE S256 and a state check; tokens go through chat/secrets.ts. Dropbox pins port 53682 because its console requires an exact redirect URI; Google and Microsoft take an ephemeral one.
  • Uploads take a simple path below ~4 MB (Drive uploadType=media/multipart, Graph PUT .../content, Dropbox files/upload) and a chunked one above it. That is not only about cost — a sidecar is a couple of KB and a session would be three round trips for nothing — neither Drive nor Graph can finalise a zero-byte resumable session, so uploadChunked rejects size === 0 outright rather than spinning on a chunk it can never produce.
  • No new dependencies. All three providers are plain REST over net.fetch, node:crypto, node:http and node:stream, plus the chokidar KKSS already ships. The official SDKs are license-compatible but each drags a large tree into out/main.js for three endpoints.

Remote (SSH) sessions ​

ssh is a fourth ProviderId (STAGED_PROVIDER_IDS = the three OAuth clouds + ssh; PROVIDER_IDS stays the account-based three, so the Cloud Accounts menu is unchanged). providers/ssh.ts implements CloudProvider over the system ssh binary with providerCore/sshCore.ts (pure: host validation, POSIX quoting, host:/abs/path item ids, ls/stat parsers, the remote command text). CloudRef.accountId is the host, so the staged path is <userData>/cloud-cache/ssh/<sha256(host,item)[0:16]>/<original filename> — remote documents share the cloud manifest, LRU budget, recents (cloud ref, exempt from the existence prune) and session restore, and CloudSync/conflictCore/companions are reused unchanged. Rules that are easy to break:

  • Never prompt, never interpolate. Every call is ssh -o BatchMode=yes -o ConnectTimeout=15 <host> <command>; isValidHost rejects a leading - (option injection) and every path goes through shQuote. The revision is mtime:size and there is no wire precondition, so the guard is check-then-upload (a narrow race remains, mitigated by the conflict-copy-on-doubt rule). Upload is cp -p (keeps the mode) + cat > tmp + mv -f over the original.
  • Terminal. TerminalService.openRemote(host) sets a one-shot target consumed by the next spawn(), which runs ssh -t <host> through node-pty; shutdown is unchanged.
  • Kratos MCP. McpManager's fourth constructor argument (kratosRemote, from settings/kratosEnv.ts's kratosRemoteHost()) replaces the Kratos spec with ssh … uvx --with 'mcp<2' kratos-mcp-server@<pin> and skips both the local uv runtime and the local Kratos env delta. classifyStartupFailure words ssh errors (Permission denied, host-key, DNS, missing uvx) as unknown so the banner never offers a local uv install for a remote problem. The chat context suffix (ChatDeps.currentFiles().kratosHost, and the staging copy of host:/path mark) carries the split between remote Kratos paths and local staging paths — not SYSTEM_PROMPT.
  • MPI runner (single node, guided solves). workflows/mpiReceipt.ts is the pure contract: mpiResources (ranks × threads per rank as the verified slot total), mpiPlanArgs (frozen into task.args so the plan fingerprint covers ranks and launcher), parseMpiReceipt (owner/request mismatch throws, bad shape is undefined), reconcileMpi and mpiRunScript. The script owns receipt.json: it runs the launcher with OMP_NUM_THREADS per rank from <run>/input, wraps every rank in a tiny sh step that records ranks/<rank>.pid (rank read from OMPI_COMM_WORLD_RANK/PMI_RANK/PMIX_RANK/SLURM_PROCID; a launcher exporting none fails the run rather than being guessed at) and writes effectiveRanks into the terminal receipt. reconcileMpi reads uncertain for a dead launcher with no final receipt, a succeeded run whose started ranks differ from the request, or a live launcher with dead ranks; exit 0 never claims convergence. workflows/mpiRunner.ts (MpiRunner) is the Runner for a solve task frozen with mpiRanks: every effect goes through an SshRunner, so runLocalSh serves this machine and the ssh runner serves remote.kratosHost with the same code. dispatch rewrites the case's ProjectParameters.json through the pure workflows/mpiCase.ts (problem_data.parallel_type: "MPI"; Kratos partitions in memory at import, so no pre-partitioned .mdpa exists) on the uploaded copy only, and refuses — failed, before anything runs — a case with a serial-only linear solver instead of swapping it. validate probes the launcher, the KratosMultiphysics.mpi/TrilinosApplication/MetisApplication imports and the CPU budget. lookup adds a per-rank pid check while the launcher is alive and reuses fetchRunArtifacts (checksum-verified downloads, shared with RemoteRunner). The launch path keeps the queue rules: an unknown launch outcome throws (so uncertain, never retried). WorkflowService.solveRunner picks the MPI runner before the remote one; kratos.mpiRanks (0 = off) is the only switch, the preview refuses when EnvironmentReport.manual.capabilities.mpi is false, and runtimeFingerprint includes mpiRanks only when set so threaded plans keep their revisions. EnvironmentReport reports mpi: true only when the probe saw a launcher (mpirun/mpiexec/srun, KRATOS_MPI_LAUNCHER first), a POSIX shell and all three MPI modules. Limits: one node (pids are local to the launcher), the ProjectParameters rewrite is verified against fixtures and stand-in launchers only, and no live MPI-enabled Kratos run has been recorded; see doc/roadmap.md item 4. Tests: mpiReceipt.test.ts, mpiCase.test.ts, mpiRunner.test.ts (killed-rank and short-rank cases), and the MPI cases in workflows.test.ts.
  • Guided solves. With a remote host set, WorkflowService.remoteSolver() sends solve tasks to workflows/remoteRunner.ts (a Runner over an injected SshRunner). It uploads the run's input/ plus a generated run.sh (sshCore.remoteRunScript) to kkss-runs/<study>-<run>, launches it setsid nohup, and reads the script-owned receipt.json for lookup/cancel (pid liveness check; owner/request mismatch throws; group TERM with the script trapping it so the receipt still lands). Process exit 0 is succeeded with a "convergence not established" note. A failure before the launch command is failed; an unknown launch outcome throws so the queue records uncertain and never retries. On a terminal receipt it downloads the run's outputs (files newer than run.sh, plus stdout.log) into the local run directory with sha256 verification, so review and discovery see ordinary local files; a failed download reads uncertain and retries. validate probes the host (python3, importable Kratos, nproc). Home's environment report is host-aware too: environment.ts's checkRemoteEnvironment runs the same PROBE_SCRIPT through probePython over the injected SshRunner (so applications, Kratos version and thread-control support are the host's), takes the CPU count from nproc, creates kkss-runs to prove the run directory is writable, and reports the local uv tool runtime as unused; the report carries host, and WorkflowService folds the host into every environment cache key so clearing or changing remote.kratosHost can never serve a stale report. Mesh/generate tasks remain local by design. remoteRunner.test.ts runs the real script in a local sh (including a relative run directory — the script resolves it to an absolute path before changing into input/, otherwise the log and terminal receipt land in the wrong place), and workflows.test.ts drives a queued solve through the service, queue and runner to checksum-verified downloaded outputs.
  • Tests: sshCore, sshProvider and sshRoundTrip run the real generated commands in a local sh (real quoting, ls/stat/cat, atomic upload, staging, write-back and conflict copy); sshStaging, terminalService, mcpRemoteKratos and kratosRuntime cover the shared plumbing; remoteEnvironment and the remote case in workflows.test.ts cover the host-aware report, and tools/e2e/remote-env.mjs drives it in the real app through a fake ssh and python3 on PATH. test/sshdLive.test.ts runs the real spawnSsh/SshProvider/RemoteRunner against an actual sshd and is skipped unless KKSS_SSHD_HOST names a key-authenticated alias whose filesystem is this machine's (a loopback sshd): KKSS_SSHD_HOST=my-alias npx vitest run test/sshdLive.test.ts.

Verifying a provider by hand ​

The consent round trip, the live endpoint shapes and resumable uploads cannot be unit-tested — they need a real registration. Per provider, once per release that touches this area:

  1. Register a desktop/installed-app OAuth client (see Configuration ▸ Cloud accounts for redirect URIs and scopes) and paste the client ID under Settings ▸ Cloud Accounts.
  2. Connect… — the browser opens, consent returns to the loopback listener, and the status row shows the account.
  3. File ▸ Open from Cloud… → open a .stp. Confirm the tab shows ☁ and the model renders.
  4. Edit something, wait ~5 s, and confirm the sidecars appear beside the model remotely.
  5. Change the file from the provider's web UI, edit locally again, and confirm a (conflict …) copy appears and the local file is untouched.
  6. Upload something over 4 MB (Graph) / 150 MB (Dropbox) to exercise the chunked path.
  7. Quit mid-upload and confirm the next launch reports the unsynced file.

Settings ​

All settings are defined in one registry, app/main/services/settings/registry.ts. It is pure: no Electron and no stateStore. Each entry declares:

  • an id (kratos.pythonPath, appearance.uiTheme, …), label, category, type, default and description;
  • the stateStore key that holds it (older keys keep their names, so nothing stored is lost);
  • when a change applies;
  • optionally, a vscode: {section, key} mapping.

Three consumers read it:

  • The shim. vscodeShim.ts's workspace.getConfiguration(section).get(key, default) looks up section.key. Sections may be dotted, as in kratos.flowgraph. A mapped key is served from the stateStore; anything else returns the caller's default. That is how every kratos.* setting the unmodified mesh providers read works, with no submodule change. The shim also implements update() and onDidChangeConfiguration.
  • The Settings page. app/renderer/settings/ runs in its own singleton BrowserWindow, owned by services/settings/settingsWindow.ts, with settings:toHost/settings:toWebview guarded by sender.
    • Main sends the schema once and then a rows snapshot on every stateStore change.
    • Every write is validated by the registry's toStored before it lands. A value equal to the default, or an empty string, deletes the key.
    • Entries with side effects route through the same functions the native menu uses: zoom, the update channel, the MCP server, cloud accounts, and the "Never ask" confirmation (menu.ts's confirmApprovalOff).
    • Secrets are write-only. The renderer submits a new value but only ever receives isSet.
  • The native menu. menu.ts keeps quick radios built from registry entries (enumRadio) and is rebuilt on any change to a registry-owned stateStore key. The page and the menu cannot drift.

stateStore.onDidChange(key, value) is the change event behind all three. test/settingsRegistry.test.ts parses both submodules' contributes.configuration and requires every property to be either a registry entry with the same type, enum and default, or listed in NOT_APPLICABLE with a reason. A submodule bump that adds a setting fails that test until KKSS decides what to do with it.

UI theme. services/appearance.ts (with the pure appearanceCore.ts) resolves uiTheme through Electron's nativeTheme to one of VS Code's four theme kinds (vscode-dark, vscode-light, vscode-high-contrast, vscode-high-contrast-light).

  • First paint. Every preload imports app/preload/appearance.ts, which fetches the snapshot synchronously (kkss:appearance). It puts the kind as a class plus data-vscode-theme-kind on <html> and <body>, and sets the UI font as CSSOM custom properties.
  • Live changes. Main re-broadcasts to every webContents when a relevant key or the OS theme changes.
  • Palettes. vscode-vars.css has a :root (Dark+) block plus one block per other kind. check-theme-vars.mjs fails the build if a block does not redefine exactly :root's colour variables.
  • CAD viewer. It restyles itself: its viewer.css keys on the same body classes, and a MutationObserver re-reads the palette.
  • Mesh viewer. Its auto scene samples the body background only when a theme is applied. The view shim therefore re-fires mesh's hidden #theme-select change on each appearance change while it is auto.
  • Editor and terminal. They read the same snapshot for their own options: CodeMirror through a Compartment, xterm through term.options.

Kratos environment. services/settings/kratosEnv.ts builds the delta that McpManager spreads over the Kratos MCP server's environment on every (re)start. It uses mesh's vscode-free problemtype/kratosEnv.ts, the same code the Run button uses. McpManager.restartKratos() backs the page's restart button.

Key pieces (all under app/):

  • app/preload/viewPreload.ts + app/renderer/view/shim.ts — the entire VS Code compatibility layer: acquireVsCodeApi().postMessage → IPC, and inbound IPC → a normal window message event.
  • app/main/vscodeShim.ts — a minimal vscode module. Its workspace.workspaceFolders is a getter backed by the explicit project folder (via the __configureVscodeShim hooks object): that is what makes ptController.discoverExternal() scan <root>/.kratos/problemtypes, so a project-local problemtype appears in the Problemtype list — impossible while this was permanently undefined. Explicit-only is a safety decision, not a style one: discoverExternal executes what it finds (sandboxed in a node:vm with no require/process/fs, no codegen and a 2 s timeout), so merely opening a mesh that happens to sit beside a .kratos/problemtypes/ must never run it. Python problemtypes stay unavailable in KKSS (pyodide is not copied into out/) and surface as a per-file error row rather than a crash. The rest of the shim (dialogs, messages, file watcher, progress, openWith, getConfiguration — served from the settings registry (see Settings above), the Settings page being KKSS's settings.json — openTextDocument/showTextDocument routed to the app's own text-editor screen, and env.asExternalUri as an identity passthrough since there is no Remote-SSH/Codespaces tunnel) that esbuild aliases in place of the real API, letting mesh/src/{mdpaEditorProvider,vtkEditorProvider,meshExport, opHistory,flowgraphController,ptController,runManager,recentMeshes,previewHtml,meshDocument}.ts run verbatim.

mesh 3.18.0 made both preview providers full vscode.CustomEditorProviders: applying an operation marks the document dirty, and only vscode.workspace.save(uri) clears it. MeshHost.resolveProviderFor mints the document through openCustomDocument(uri, {backupId: undefined, ...}, token), keeps its provider/document pair, and the shim's saveMesh(fsPath) hook calls that host's saveDocument()/saveCustomDocument. MeshHost.isDirty() backs tab dots and close/quit prompts. Three modules remain deliberately unreachable rather than shimmed—runTreeView.ts, sidebarViews.ts and emptyPreview.ts, constructed only by upstream activate(), which KKSS never calls—so TreeView/registerCommand APIs remain unsupported. The script-free kratos.exportReport panel (enableScripts: false) uses services/staticReport.ts: sandboxed escaped HTML, JavaScript disabled, no Node/preload, blocked navigation/popups and inline trusted theme variables. Live reports retain their reportSink and IPC. Mesh 5.1 adds a separate narrowly trusted plotting route, described below; it never relaxes the report window's script prohibition.

Mesh 4.23 integration also stages mesh/dist/streamlineWorker.js as out/streamlineWorker.js, beside main.js, because the upstream client resolves it via __dirname. The build requires this artifact just like mmgWorker.js. Mesh 5.5.0 adds the same contract for its cancellable worker fleet — out/{analysisWorker,seriesScanWorker,oracleOpWorker,meshioWriteWorker}.js — and MeshHost mirrors activate()'s configureOracleRunner/configureMeshioWriteRunner plus the linked-preview pickPeer (modal quick pick over the hub's candidates) the same way it already mirrored configureMmgRunner. Native Import Mesh dispatches menuImport to the active provider; grouped exports are sourced from upstream EXPORT_MENU_GROUPS, not a duplicate format list. Packing uses the active host's current file rather than a VTK-only accessor, so numbered MDPA series also work. test/meshMcpRuntime.test.ts checks the staged worker and live tool schemas against the approval policy; tools/e2e/mesh-423.mjs drives imports, traces/cancellation, reports, Fluid validation, quantitative flow-series CSV, PVD packing/reopening, display units and density-field conversion in the real Electron app with an isolated profile.

Mesh 5.1 scientific plotting integration ​

tools/webviewMarkup.ts mirrors upstream PLOT_PANE_HTML after Flowgraph, links plots.css, and gives #preview-main its lazy local Plotly URL. esbuild.mjs requires/stages plotWorker.js beside main.js/mcpServer.js, and copies plots.js, plots.css and the strict Plotly bundle/license into out/renderer/mesh/. Plotly requires neither a CDN nor an unsafe-eval CSP exception.

The second narrowly supported createWebviewPanel route is script-enabled kratos.plotBuilder. services/plotPanel.ts loads trusted generated plots.html, uses the existing sandboxed/context-isolated view preload with sender-scoped plots:* IPC, and blocks navigation/popups. Closing unregisters IPC and disposes the upstream controller/worker. It cannot load arbitrary HTML/general panels; export reports stay JavaScript-disabled. File's native builder supplies the same ExtensionContext/run index as the providers.

vscode.openWith preserves explicit ViewColumn.Beside: index.ts reveals an existing owning result tab or creates a separate one before opening, without replacing unrelated/dirty documents. Upstream verifies revisions before/after frame adoption; MCP target resolution alone never navigates a GUI.

Seven plotting tools have explicit policies: mesh__plot_dataset is write-classified because CSV/provenance export is optional, and mesh__plot_preset (mesh 5.10.0) is write for the same reason — it is read-shaped until given evaluate/threshold/differences, and it carries no toolPaths.ts scope row because its opaque params can hold a table path the heuristic cannot account for. Table inspection, run discovery/binding, time cursors and run targets are read-only. The system prompt describes physical/ownership limits.

publish.mjs freezes the independently verified generated cases (re-running verify.py, hashing every published file, recording the submodule revisions) and rebuilds the per-case and combined archives; it never publishes solver logs or runtime receipts. writeDocs.mjs regenerates the tutorial pages and the landing table from those verification records. Linux acceptance does not imply Windows/macOS GUI verification.

  • app/main/cadHost.ts — a 1:1 port of cad/src/provider.ts's editor session (the cad provider imports OCCT directly, which must live in a worker here, so the cad side is ported rather than shimmed). Its readOcctSource is the single choke point every OCCT path reads through, so a .scad is converted to .csg by the user-installed openscad binary once and nothing downstream ever sees format "scad". Deliberately not ported: SpaceMouse (it needs node-hid, a second native module — see the node-pty section) and the Models activity-bar view (a VS Code TreeView with no KKSS analogue); nor the provider's getHtml/getNonce (KKSS generates its page with tools/gen-webview-html.mjs, whose CSP allow-lists the kkss: scheme instead of a nonce) and its registerCommands (KKSS's command surface is app/main/menu.ts, and its What's New lives in app/main/services/whatsNew.ts). Its CadHostHooks.onMeshExported fires after a meshing-panel export writes a file (now carrying both the exported path and the source CAD path); app/main/index.ts wires it to refresh a matching clean mesh tab or create a new one (gated by modeForFile) so a mesh exported in pre mode that post mode can display (.mdpa, .vtk, …) opens straight into the mesh view — and then translates the source's <model>.layers.json structure into the target's <stem>.kratosview.json. Layer sync is bidirectional (app/main/services/layerSync.ts, pure and unit-tested): shared .stl/.obj/.ply files re-sync the same way on open and on tab focus, last-writer-wins by sidecar mtime. Only structure travels (names, colours, visibility, lock — never membership, which lives in incompatible B-rep vs element id spaces); the merge is union-only so repeats converge. cadHost.ts also ports the layers session (currentLayers + never-recycled layer-N counter, layersChanged autosave, ready hydration, rebind across op changes), the drawing-export layer filter (pickLayersFilter: All / Visible / Named, B-rep only), and the OpenSCAD backend/library-path settings through readOcctSource; remeshMesh arrives via the existing meshioService worker module plus a typed client binding. Mesh-source meshing fallback lives in app/main/cadMeshInput.ts: when the viewer has not sent its already edited STL, KKSS resolves the original source and companions, then replays only the unbaked tail through CAD’s bakeMeshEdits in cadCompute.worker.ts. Keep that method in the worker and typed client; a displayed STL is already edited and must never be replayed twice. The B-rep geometry post is the provider's full message — autoFit, opOutcomes, guideIds and opBuckets beside the tessellation. The kernel replays only the unbaked tail, so app/main/cadOpOutcomes.ts's rebaseOpOutcomes (pure, test/cadOpOutcomes.test.ts) pads the baked prefix as applied and shifts outcomes and buckets by bakedThrough; ready's loadModel(false) marks a genuine open (autoFit: false forces a re-frame) and an edit-driven rebuild keeps the default. File ▸ Save bakes (CadHost.saveDocument, the port of the provider's bakeTailToSource/bakeMeshToSource): the ordering — confirm, one-deep .bak, temp sibling + rename, then the watermark, with the source rolled back if the watermark write fails — lives in app/main/cadSaveInPlace.ts, Electron-free with every side effect injected (test/cadSaveInPlace.test.ts drives it against an in-memory filesystem that can fail any call). Two deliberate departures from the provider: mesh bytes come from the compute worker's bakeMeshEdits rather than the webview's exportMesh round trip (it needs no WebGL scene and keeps edit replay in the worker), and the save-time Part/annotation/layer rebind (rebindPartsAcrossSave, bound in cadComputeClient.ts) is given the unbaked tail as the pre-save ops, never the full history — the pre-save bytes already contain the baked prefix, so the provider's and cad__save_model's full-history argument double-applies it on every save after the first. CadHostHooks.onDirtyChanged drives the tab dot (syncTabs reports host.isDirty() for both modes) and confirmSaveInPlace is the modal; index.ts's confirmDiscardTab serves tab close, document replacement (openFile → documentReplacementQueue) and the window-close loop for CAD and mesh tabs alike. A baked mesh source is reloaded with a ?v= query on its kkss-file: URL (the protocol handler routes on the path alone), since the bytes at the same URL just changed. There is no source watcher in KKSS, and the chat's CAD MCP child can still run cad__save_model on a file a tab has open — the same cross-process gap the headless tool already documents.

Three things to keep in step with the provider on a bump: plane references are resolved before the first tessellation (ready loads edits and planes together and runs resolvePlaneRefs, and loadModel resolves the replay tail again — a plane-authored profile carries no placement of its own since cad 2.5.0, and the kernel skips one that was never resolved); the meshing-preset library (cad-preview-mesh-presets.json, per folder like the macro library, atomic-written, never cloud-synced, with the bundled starters read from out/cad-runtime/dist/mesh-presets/); and the Standard-Parts thumbnail cache, which is module-level because the provider's is shared by every document while a CadHost is per tab. Since cad 3.14.0 two more: the XCAF assembly tree (loadBRepCachedInWorker passes deferAssemblyTree: false, so the STEP assembly is read synchronously; the deferred read crashed the kernel, see CLAUDE.md — the dormant assemblyPending upgrade (resolveAssemblyTreeForDocument → a second tree{upgrade:true} post, epoch-guarded) stays wired for re-enabling) and compressed-glTF buffers (gltfBuffers threads a worker-side decodeGltfGeometry decode into resolveExternalBuffers, so Draco/meshopt files parse; the dist/draco/ staging in esbuild.mjs is what makes both ends resolve).

  • app/main/services/jsonStore.ts — the Electron-free half of stateStore.ts (the same split as chat/secretCodec.ts under chat/secrets.ts, and unit-tested by test/stateStore.test.ts). It is instantiated per file — state.json plus one instance per chat conversation and one for their index — so each file gets its own writer chain and will-quit flushes each of them. Every write goes through app/main/services/atomicWrite.ts — a sibling temp file, fsynced, then renamed over the target (atomic on POSIX and NTFS) — behind a single-writer chain, so concurrent update() calls cannot interleave or tear the file. This is not incidental: the LLM API key and the MCP meta-server's bearer token live in that same state.json, so one torn write used to lose every setting and both credentials at once, after which the store's silent corrupt-file fallback booted the app looking factory-fresh. A write that is still queued absorbs later mutations rather than making them wait, which is what the many fire-and-forget void stateStore.update(...) callers (every Settings menu click, the zoom picker, the mesh Memento) want. will-quit cannot await, so it calls flushSync() — an in-flight async write can only resume after that returns, and re-checks a stopped flag before its own rename, so it can never land a stale snapshot on top.
  • app/main/services/atomicWrite.ts (tested by test/atomicWrite.test.ts) — the temp-file + fsync + rename primitive, extracted from jsonStore.ts so cadHost's ten sidecar writers get the same guarantee. It also serializes writes per resolved path, which is not optional once a temp name is involved: two overlapping writes to one path would share <file>.<pid>.tmp and the second rename would fail with ENOENT. Two such overlaps exist in cadHost.ts — flushSidecars() clears the seven debounce timers but cannot cancel one that has already fired, and cad-preview-macros.json / cad-preview-mesh-presets.json are per-folder libraries that two tabs on models in one directory both write. The sync variant deliberately skips the fsync and the Windows rename retry (it runs on the quit path, where being fast matters more than being durable) and uses a distinct .sync.tmp name — it bypasses the chain, so sharing the async temp name would let a write that is mid-open() end up holding a handle to the file the sync write has just renamed into place. Sidecar suffixes live in app/main/services/sidecarSuffixes.ts, imported by both cadHost.ts and the cloud layer so the two lists cannot drift.
  • app/main/services/recentFiles.ts (+ the pure recentFilesCore.ts, tested by test/recentFiles.test.ts) — the app-wide recents list, covering both modes. Recorded at exactly one choke point, openFile(), which is why every user-facing open routes through that function; the three host.openPath() callers that bypass it (crash replay, openLatestResults, onMeshExported) and session restore deliberately record nothing. De-duplication is by path with the mode refreshed, so a format both modes can read is one row that remembers where it was last opened. An entry carrying a cloud ref is exempt from the existence prune: its local copy is a staging copy the cache may have evicted, while the remote file is still there, so the row survives and clicking it re-downloads. The mesh submodule's own RecentMeshStore keeps running — both providers require it — but no longer drives any UI. recentKey/recentLabel/recentDescription are reused from mesh/src/recentMeshesCore.ts, which is vscode-free; its recordRecent/parseRecentList are not, because they would silently drop the mode field.
  • app/main/services/projectRoot.ts (+ the pure projectRootCore.ts, tested by test/projectRoot.test.ts) — the project folder. Two levels: explicit (chosen via File ▸ Open Folder…, persisted under projectRoot) and effective (explicit, else the focused document's directory). Consumers use effective(), so with no explicit root every one of them behaves exactly as it did before the concept existed. Only explicit() is ever displayed or handed to the shim — an inferred root would change with the focused tab, and would put a developer's absolute path into the committed docs screenshots. A stored root that has been deleted or unmounted degrades to "none" on read but is deliberately not erased. describeWithin() is what makes Open Recent read as "this project": entries inside the root show their folder relative to it, everything else keeps the ~-abbreviated form — no re-sectioning, which would fight the list's newest-first order.
  • app/main/services/session.ts (+ the pure sessionCore.ts, tested by test/session.test.ts) — session restore. Persists each mode's open documents in tab order plus the focused path (tab ids are a per-process counter, and an index shifts when pruning drops an earlier file), the screen, and panel visibility. Captured debounced from syncTabs()/setScreen()/the panel toggles — so a SIGKILL still leaves a usable session — and once more on will-quit before stateStore.flushSync(), since that call stops the store and would swallow a later write. Restoring prunes vanished paths first, opens each file with openPath() (append, no re-routing, no recording), closes the blank starter tab, then restores screen and panels. Gated by KKSS_E2E, KKSS_NO_RESTORE=1, and Settings ▸ Restore Last Session. Evicted cloud documents are the one exception to prune-first: the session also stores each open cloud tab's ref (ModeSession.cloud, optional so SESSION_VERSION stays 1, filled from CloudService.refFor) because eviction deletes the manifest entry with the file and the staging path is a one-way hash. planRestore (the pure successor of pruneSession for restore; loadSession() now returns its RestorePlan) keeps such a file as a pending slot, restore creates its placeholder tab synchronously, and restoreCloudTabs in index.ts fills it afterwards through downloadCloud (shared with openCloudFile) under the policy in services/cloudRestore.ts (test/cloudRestore.test.ts): sequential, only into a still-blank tab, one not-signed-in notice per provider, failure closes just that placeholder, openPath rather than openFile, no recents. tools/e2e/cloud.mjs's cloud-restore-evicted drives the real round trip.
  • kkss:// and kkss-file:// schemes — replacements for asWebviewUri/localResourceRoots (app assets and allow-listed user files respectively). External kkss://open?file=… links are validated by app/main/launch.ts and enter the same deferred queue as Finder and second-instance opens. An existing file the router cannot route is not queued: unsupportedLaunchFile(s) lets index.ts's reportUnsupported answer it (and the in-app openFile() path) with one warning toast carrying a Supported formats action — toast(kind, text, action) in services/notifications.ts, whose action toasts are retired by main on click or after 15 s because the shell never auto-removes a toast with buttons. shellToast defers it until the shell is up. Native associations and Linux MIME entries are checked against the CAD and mesh registries by npm run check:packaging.
  • app/main/cadCompute.worker.ts / cadComputeClient.ts — the RPC boundary for everything WASM-backed on the cad side. The worker spreads sixteen modules into one flat method map (the submodule's occtService, gmshService, massProperties, entityFacts, meshioService, meshioRegionParts — CPU-only but pulls in Three.js — hitTestService, meshHeal, primitiveReport, meshRegionFit, primitiveWrite, svgSilhouetteHost, modelDiffHost and stepPartsService, gltfDecode (cad 3.14.0's Draco/meshopt expansion), plus KKSS's own cadBRepCache), and throws at startup if two export the same name; the client re-exposes each with the submodule's own signature via Parameters<typeof …>, so a changed signature is a type error rather than a runtime surprise. Pure text helpers (stepUnits/igesUnits unit detection) stay in the main process — a worker round trip would buy nothing. Since cad 3.0.0 the client also tracks kernel readiness for the status bar: cadKernelStatus.ts (Electron-free, so unit-tested) feeds cad's own reducer from start/success/failure events around every call and reset when the worker dies, translating KKSS's two differently-spelled RPC names (loadBRepCachedInWorker, releaseBRepCache) through KKSS_FN_ALIAS (resolveAssemblyTreeForDocument and decodeGltfGeometry match their pipeline keys exactly, so they need no alias); cadHost.ts fans changes out as kernelStatus to every open CAD tab, and also posts documentInfo (the menubar chip — see Pre-Processing mode).
  • CAD-Preview 3.4 interactive additions — CadHost handles meshSweepRequest with the shared runMeshSweep loop, document-owned cancellation, optional folder selection and .msh writes; computeHoleTable is exposed through the existing mass-properties worker module for holeTableRequest. The generated webview is sourced from the submodule's current markup and bundle, so these controls require no KKSS page copy.
  • app/main/cadBRepCache.ts — cad 1.2.6's loadBRepCached reuses a parsed base shape across interactive edits, but the cache entry it hands back owns live OCCT handles and can never be structured-cloned. So the entry lives in the worker beside the OCCT singleton that owns it, and only the plain BRepResult crosses the RPC. Entries are keyed by a stable per-tab session id, so every open cad tab gets its own slot; releaseBRepCache is the counterpart of the provider's onDidDispose teardown and is called from CadHost.disposeSession. On a thrown load the entry is dropped, never disposed — loadBRepCached's own contract, since a WASM abort may have left it half-torn-down.
  • app/main/cadMeshioLoader.ts — esbuild aliases it in place of @meshioplusplus/wasm in the cad worker. cad/src/meshioService.ts loads meshio++ with a bare await import(...) and no directory fallback (unlike mesh's own loader, which ends at __dirname/meshio), which would be ERR_MODULE_NOT_FOUND in a packaged install where KKSS ships no node_modules. The shim resolves the copied out/meshio/ tree and passes a name-aware locateFile. It must not use require.resolve("@meshioplusplus/wasm/package.json") — the alias catches that subpath too, and esbuild would try to resolve it against the shim file itself.
  • tools/gen-webview-html.mjs (→ tools/webviewMarkup.ts) — builds each mode's HTML page from the submodules' own markup modules (viewerDom.ts, webviewChrome.ts) at build time, so the DOM always matches what the extensions expect. The mesh page must mirror webviewChrome.buildPreviewHtml's body element for element (since mesh 3.15.0 that one function is the skeleton both editor providers and the standalone empty panel share, via previewHtml.ts; it can't simply be called, because KKSS links two extra stylesheets and must load shim.js before the bundle), and links three stylesheets in order: the submodule's design-system.css (the --ds-* token layer style.css builds on), style.css, then app/renderer/theme/mesh-overrides.css.
  • app/renderer/theme/mesh-overrides.css — KKSS-only overrides for the mesh webview. It hides #file-menu and #theme-select: mesh 3.0.0 put the viewer's File menu and scene-theme picker in an in-flow #menubar strip, both of which the native menu already owns. The markup is still emitted (webview/main.ts looks those nodes up by id), so anything reachable only from that menu — Save/Load Problem — needs a native menu entry. The strip itself stays visible since the mesh UI redesign, because it holds the document chip (#doc-chip: file name, format badge, "N unsaved edits", fed by the documentInfo message), and the page now ends in a #statusbar (engine activity from engineStatus, model counts, timeline frame, last Inspect pick). Both messages ride MeshHost's unfiltered postMessage relay — no KKSS-side handling — so a mesh bump only has to keep meshBody() mirroring buildPreviewHtml (the status bar is STATUSBAR_HTML, emitted after #main). The engine line reflects only the main process's engines: the chat sidebar's mesh MCP server is a separate child with its own meshio++/MMG, which the chip does not see. Note the converse: mesh 3.14.4's Edges toggle lives in the visible View ▾ popup, not the strip, so it deliberately has no native entry (and no host message that could drive one).
  • app/renderer/theme/vscode-vars.css — the --vscode-* theme variables VS Code normally injects; tools/check-theme-vars.mjs fails the build if a submodule update uses one that is missing, and likewise for a --ds-* token style.css uses but design-system.css doesn't define. The guard also scans every stylesheet under app/renderer/, so a --vscode-* variable used by KKSS's own pages must be defined here too.
  • app/renderer/theme/kkss-ui.css + app/renderer/glyphs.ts — the design layer KKSS's own pages (shell, home, editor, terminal, chat, jobs, About, What's New, picker) share, matching CAD-Preview v3.0.0's look. kkss-ui.css holds the --ui-* metric tokens (radii, spacing, type sizes, control heights — never colour) and the shared recipes: .btn (ghost by default; -secondary/-primary/-outline/-sm/-link), .icon-btn, .tb-div, .seg (segmented control), .section-header/.panel-icon/.eyebrow, .card, .field, .ui-badge/.ui-dot, .toast, .spinner, the global [hidden] rule and the :focus-visible ring. Every page links it right after vscode-vars.css and before its own stylesheet, which therefore holds only layout and page-specific bits (a page that needs a pixel value uses the token scale: 5px controls, 8px cards). glyphs.ts's glyph(id, size?) re-exports cad's line-glyph set (cad/src/uiGlyphs.ts) plus KKSS extras (x, menu, send, history, …); add a missing icon to KKSS_GLYPHS there. The generated TikZ shell/shellIcons.ts still supplies the mode/home/open/edit/terminal/chat/settings/help icons. Strict-CSP pages (style-src kkss:) must not use inline style= attributes — toggle the hidden attribute instead.

Building from source ​

bash
git clone --recurse-submodules https://github.com/loumalouomega/KKSS.git
cd KKSS
npm ci
npm run submodules:install   # npm ci in cad/ and mesh/
npm ci --prefix doc          # VitePress deps for docs:dev / docs:build (the manual)
npm run build                # submodule bundles → app bundles → HTML gen → theme guard
npm start                    # build + launch
npm run dist                 # package installers into release/

Day-to-day:

bash
npm run build:app   # skip the submodule rebuild when only app/ changed
npm run typecheck
npm test            # vitest glue tests (test/)
npm run smoke       # headless end-to-end smoke test (needs xvfb on Linux)

Dependency security pins ​

Only node-pty is a runtime dependency, but that understates the shipped surface: esbuild bundles several devDependencies into out/main.js (@anthropic-ai/sdk, @modelcontextprotocol/sdk, electron-updater, semver, and everything they pull in). A "dev-only" advisory on one of those transitives is therefore a real advisory against the packaged app, so check where a flagged package actually lands before dismissing it:

bash
npm ls <package> --all      # who requires it
grep -c "<package>" out/main.js   # does it reach the bundle?

When an upstream range still admits a vulnerable version, pin it in the root package.json's overrides block (the cad/ and mesh/ submodules keep equivalent pins for their own trees) and drop the entry once upstream's own range excludes the bad versions. fast-uri (at least 3.1.8) and @hono/node-server reach the bundle through @modelcontextprotocol/sdk (via ajv and the SDK's streamableHttp.js transport respectively). Build-time dependencies need updates too: the electron-builder tree uses brace-expansion 1.x, 2.x and 5.x, so version-scoped overrides require the patched 1.1.21, 2.1.7 and 5.0.12 releases without changing the majors requested by its different minimatch consumers. Never apply a blanket major override or npm audit fix --force to this tree; that can break packaging.

The download toolchain also pins http-cache-semantics to at least 4.3.0, outside GHSA-ch52-4w7c-c8xp's published affected range (<=4.2.0). The advisory does not yet identify a patched release, and 4.3.0's upstream changes address a separate Vary issue; a clean audit is not confirmation that the reported max-stale behavior is fixed. This dependency is build-time only, not a shared HTTP cache in the shipped app. Recheck the upstream advisory before claiming that behavior is fixed.

test/dependencySecurity.test.ts checks every matching root-lockfile entry, including nested copies, against these version floors. After refreshing the lockfile, run npm audit, the build/tests and Electron smoke verification; exercise packaging as well when its dependency tree changes.

Regenerating documentation screenshots ​

Screenshots are generated, not hand-captured — the same philosophy as the cad submodule's scripts/screenshots/ pipeline, but even more end-to-end: tools/screenshots.mjs launches the real Electron app (Playwright-Electron) on real example files from the submodules and captures the live windows at 2x pixel density.

bash
npm run build                          # once, so out/ is complete
env -u ELECTRON_RUN_AS_NODE xvfb-run -a npm run docs:screenshots   # headless Linux

PNGs land in doc/public/screenshots/ (committed, kebab-case) and the two README heroes are refreshed in images/. Any change to the shell toolbar, the generated webview pages, or visible viewer behavior means re-running this — don't hand-edit the PNGs.

npm run docs:screenshots -- --animations-only (or the full run) also writes the timeline GIFs for the fluid, FSI and CoSimulation tutorials. tools/tutorials/animations.mjs opens a temp copy of each case's vtk_output series in the real app (no solver run; the published archives no longer ship those series, so a missing one is skipped with a warning and its committed GIF stands), pins the Field panel's Range so colours are comparable across frames, steps #tl-scrub every third frame (ending on the last), hides the floating chrome and screenshots the canvas. Frames are decoded and resized by Electron's nativeImage in the main process and encoded by tools/lib/gif.mjs, a dependency-free GIF89a encoder (median-cut global palette, no dithering, deterministic; test/gifEncoder.test.ts round-trips it through an independent decoder). Each GIF is capped at 1.5 MB, and the script fails if the first and last frames are identical. Shallow water is intentionally not animated: it is a still basin, so every frame is the same. Pressure starts at step 5 because the impulsive start would dominate a fixed colour scale.

Tutorial figures and capture ​

The geometry-to-results tutorials are illustrated from the real app. Solver output does not ship in the repository (commit b9f83b1 removed the published vtk_output/ folders), so the figures are generated from the recovered output, and the pages' verification sections are rendered from the published records.

CommandProduces
npm run tutorials:manualGeometry, setup, setup-conditions, solver and mesh shots for each case (the CAD export and the Problemtype are replayed in the app; the solver is not run).
npm run tutorials:galleryField and plot shots from the recovered frames. Plot shots load each case's restored recipe and check the drawn traces against the CSV it was published with.
npm run tutorials:animationsThe animated GIFs listed in the catalog.
npm run tutorials:docsRenders the generated sections of each tutorial page from its verification record. tutorials:docs:check fails if any is stale.
node tools/tutorials/manual.mjs --wizard wind-tunnelThe six virtual wind tunnel wizard steps, shot in the mesh viewer on the published body.
node tools/tutorials/manual.mjs --fe-meshThe CAD window after FE Mesh Generate for each case (tutorial-<id>-fe-mesh.png).
node tools/tutorials/manual.mjs --fit-cadThe mesh → CAD example's two CAD shots: the STL skin (fit-cad-source.png) and the fitted STEP reopened (fit-cad-fitted.png). Both run inside npm run docs:screenshots (with a --fit-cad-only shortcut); the files already exist, so no fit runs at capture time.

tools/tutorials/shots.mjs is the catalog of every file under doc/public/screenshots/: which producer writes it, and the field, range, step and view it uses. test/tutorialDocs.test.ts checks the catalog against the files on disk and the pages against the catalog.

The results provider (tools/tutorials/results.mjs) reads, in order: KKSS_TUTORIAL_RESULTS/<case>/vtk_output; a cache under node_modules/.cache/kkss-tutorial-results/ filled byte for byte from the last commit that shipped the frames; and a local generation whose mesh.mdpa hashes to the published record. A case with none of these fails with the command that would produce it.

Eigenmode figures. The eigenvalue run writes its modes to EigenResults/*.post.bin, and this build's GiD binary reader misreads their vector records (most nodes come back as zero or another node's value). tools/tutorials/eigenModes.py reads the raw records, checks their layout on every block and writes the same three modes as eigen-modes.vtu for the viewer, into the generation's scratch folder. The eigen figures come from that file; nothing is published from it.

No solver output at hand. With KKSS_TUTORIAL_SOLVE=1, results.mjs runs generate.mjs <case> into a scratch generation when no override, git or local source exists; its mesh can differ slightly from the published one.

Solver runs and publishing. generate.mjs runs the tier-4 Kratos runtime on each case. A case whose stock wrapper imports a module the package does not ship sets solverAlias in cases.mjs, and runtimeAlias.mjs writes the one-line re-export beside the package before the solve (idempotent). The two-fluid case needs it: the serial wrapper imports navier_stokes_two_fluid_solver, and the package file is navier_stokes_two_fluids_solver.py. publish.mjs ships the text files with machine paths scrubbed (the repository is relative, the generation folder is generated/, the home directory is ~) and refuses to publish a file that still names one.

Capture windows open on a private Wayland compositor when KKSS_TUTORIAL_WAYLAND names its socket, so they never take focus or appear on the desktop. On KDE, start one with:

bash
kwin_wayland --virtual --socket kkss-capture --width 1800 --height 1700 \
  --no-lockscreen --no-global-shortcuts --no-kactivities &
KKSS_TUTORIAL_WAYLAND=kkss-capture npm run tutorials:gallery

Without the variable the scripts open windows on the current desktop and say so once. The viewer writes view-state sidecars (*.kratosview.json) beside the frames it opens, so the scripts work on copies and clear those sidecars before each launch; never point a capture at the result cache.

Icons ​

icons/ holds TikZ-drawn icon sources, mirroring the submodules' pipeline (pdflatex + pdftocairo required — see icons/README.md):

  • tikz-ui/*.tex → svg-ui/*.svg → the generated (and committed) app/renderer/shell/shellIcons.ts — monochrome currentColor shell toolbar icons, tinted by the surrounding element's color.
  • tikz-app/kkss.tex → icons/app/icon{,-256,-1024}.png — the teal tessellated-kiss application icon consumed by electron-builder.yml and the Linux window icon (out/icon.png).

Regenerate everything with npm run build:icons and commit the sources together with the regenerated artifacts.

Updating the submodules ​

Upstream improvements are inherited by bumping the submodule pointer:

bash
git submodule update --remote cad    # or mesh
npm run build                        # rebuilds the bundle + re-runs check-theme-vars
npm test && npm run smoke            # protocol drift shows up here
git add cad && git commit -m "Bump cad submodule"

The typecheck imports the extensions' protocol types and the build re-greps their stylesheets, so a breaking protocol or theming change fails loudly rather than silently misbehaving.

If a change inside a submodule is ever unavoidable, commit it to a dedicated branch in that submodule (e.g. application-downstream) and point the KKSS gitlink there — never to the submodule's default branch.

Releasing ​

Tag and push: git tag v0.2.0 && git push --tags. The release workflow builds Windows/macOS/Linux installers and attaches them to a GitHub Release; tags containing a hyphen (v0.2.0-rc1) are marked as prereleases.

Full Electron acceptance suite ​

After npm run build, run npm run e2e (xvfb-run -a npm run e2e on headless Linux). CI runs this suite sequentially, without assertion retries, and uploads test-results/ logs, page URLs, HTML and screenshots on failure. npm run smoke remains the shorter viewer/Home check. Individual scenarios live under tools/e2e/ and can be run directly with Node.

The suite exercises generated CAD/mesh DOM, native menu/shim reload, CAD → mesh export and repeat-export refresh, editor saves, PTY output, tabs and actual view zoom, streamed chat with approval/denial/cancellation and transcript persistence, authenticated HTTP MCP enable/call/disable, cloud upload draining and recovery, session restoration, and real single-instance acquisition/forwarding/reacquisition. Assertions check rendered state, disk contents and service effects. Linux is the acceptance platform; these tests do not claim macOS/Windows validation.

Profiles and documents are temporary. Chat uses a loopback OpenAI-compatible SSE server and Kratos uses a local stdio MCP fixture through a temporary uvx wrapper. No provider credentials or solver installation are needed. CAD and mesh retain their real bundled runtimes. The cloud transport fixture activates only in unpackaged apps with KKSS_E2E=1 and an absolute KKSS_E2E_CLOUD_DIR; it replaces only Dropbox's provider implementation, preserving the real cache, watcher, sync and quit paths. It has no renderer IPC controls. Upload barriers prove quit waits; failed and stalled transfers retain dirty state and can be retried after relaunch.

launchApp accepts env, restore and singleInstance options. restore: true sets KKSS_E2E_RESTORE=1; explicit KKSS_NO_RESTORE=1 and the user's Restore Last Session setting still take precedence. Defaults continue suppressing restoration and bypassing the instance lock for smoke and screenshots. Lifecycle tests use bounded graceful quit and observe the process exit; forced cleanup cannot count as acceptance.

Quick-pick options close their own BrowserWindow immediately on selection. Use tools/e2e/picker.mjs's clickPickerOption for these clicks: it arms the close waiter before input and accepts only the target-closed acknowledgement race after the picker actually closes. It retains actionability checks and bounded waits; other click errors or crashes still fail. Always assert the selection's effect in the surviving app or on disk afterward, so closing a picker without completing the requested operation cannot pass the scenario. test/pickerInteraction.test.ts covers both acknowledgement/closure orderings and failure propagation.

Regression fixes found by this suite: assistant completion replaces the streaming bubble without also rendering the persisted entry; opening a staged path restarts upload tracking; repeated CAD exports refresh a matching clean mesh tab, while unrelated or dirty tabs remain intact.

Interface language and accessibility ​

app/shared/i18n/en.json is the typed English message catalog and stable source key set; es.json supplies Spanish text. t() interpolates values without translating filenames, setting IDs, serialized values, logs or provider content, and falls back to English when an upstream or unknown message has no Spanish entry. The general.language setting is stored as uiLanguage; locale is resolved once at startup and sent to renderer pages through the locale preload bridge. Language changes require restart so all app windows and the native menu use one locale. Embedded viewer pages are owned by their upstream submodules.

windows.ts moves focus between visible WebContentsView regions on F6 and Shift+F6 and restores focus to the invoking region after closing a panel. Document tabs use a roving tab stop and arrow/Home/End navigation. Renderer accessibility acceptance lives in tools/e2e/accessibility.mjs; it scans representative app-owned pages with axe-core in both locales and all four application themes, then checks focus movement and language persistence.

npm run perf is an opt-in Electron benchmark, separate from CI gates. It measures five fresh launches to Home readiness and five file opens each for cad/examples/STP/bull.stp and mesh/src/test/fixtures/regions/UUea.inp, then reports samples and medians. These are fresh processes with fresh profiles; filesystem caches are uncontrolled. The checked-in baseline records fixture hashes, revisions, runtime and host metadata. Runs warn above three times the baseline; PERF_STRICT=1 makes those warnings fail. Use npm run perf -- --update-baseline only after reviewing a deliberate change; the ordinary command writes only test-results/perf.json. The timing hooks behind it are the opt-in KKSS_PERF_TRACE instrumentation — read only by an unpackaged app and ignored in installers — which records Home's interactive acknowledgement and file-open completion, correlated by absolute path. Failure to reach one unambiguous completion marker always fails the run. The meshio++ fixture's model arrives in the vtkFrame protocol message, which is the timed model-load completion signal.

The 2026-09-24 baseline on an AMD Ryzen 7 255 / Linux x64 software-rendered host measured medians of 753.3 ms to interactive Home, 2,233.6 ms for the CAD geometry fixture and 517.5 ms for the meshio++ model fixture. These are reference measurements from fresh processes; the harness neither warms WASM nor controls operating-system filesystem caches, so they are not cold cache figures or cross-machine performance targets.

Mesh 4.6 integration ​

The embedded upstream viewer supplies Selection, the Properties editor and line probes. Existing provider delegation handles menuExportSelection and meshAnalysis probe requests without new application IPC. Selection and Properties entry points come from the shared toolbar/Advanced markup; the panels are created by the viewer at runtime. The mesh stylesheet overrides give the Properties Clone button and assignment/value controls enough space in the embedded panel.

Chat classifies mesh__mesh_select and mesh__mesh_probe as write because outputPath can write uncapped selection ids to JSON or a probe profile to CSV, matching the policy for optional table/series output. Property and entity mutations remain under mesh__mesh_transform. The Electron mesh-submodule-features scenario covers these provider paths in the real application.

The shared packaged meshio++ tree comes from mesh's locked 17.13.0 dependency; CAD declares 17.13.0 and resolves the same staged tree at runtime, so the pair is verified by these two tests rather than by the pins agreeing. Bundled MCP regression tests exercise mesh field interpolation/export/reopen and CAD boundary conversion/meshing against the shipped tree. Recheck both engines whenever either dependency changes; a version mismatch alone is not evidence of runtime compatibility.

Home forms and guarded document replacement ​

Home’s WorkflowForm renders one inline action panel using the shared input/button recipes, with visible validation, focus restoration and retained values on errors. Existing home:toHost workflow messages and app__* schemas are unchanged; submissions are serialized in the renderer. Queue previews, paused persistence and starting remain separate user decisions. Raw MCP calls wait for the target server’s in-flight connection, so a first quantity evaluation can start from Home without opening chat or waiting for the independent Kratos runtime.

openFile now returns a promise and uses documentReplacementQueue to serialize replacement consent. It captures and rechecks the destination tab/host/document before and after the dirty-mesh Save / Don’t Save / Cancel guard. Only a successful replacement updates recents and cloud tracking. The shim awaits its openWith hook. Shared Home/shell/view preloads route the first OS dropped file over app:dropFile; main validates the sender and absolute path before entering the same guard. External launch requests reserve separate tabs; restore and crash replay retain their existing paths.

tools/format-registry.mjs parses source routing registries for packaging and separately counts unique extended reader/writer identifiers. The packaging CI check also checks the explicit format counts in the landing page and guides. Suffix aliases are not additional formats.

Released under the AGPL-3.0-or-later License.