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.
- Review upstream release notes and the source diff. Keep app-only behavior in KKSS. Required MCP contracts belong on the submodule's
kkss.devbranch and extension changes onapplication-downstream; commit those upstream changes before the gitlink bump here. - Run
npm run build: it builds CAD and runsnpm run package --prefix meshbefore the parent build copies artifacts. Check thatout/meshio/dist/meshioplusplus_wasm{,_mt}.{mjs,wasm}contains all four files, and thatout/ftetwild/came fromcad/dist/ftetwildrather thancad/node_modules. Since cad 3.11.0 also checkout/cad-runtime/dist/mmg-core.wasm(the remesh binary, fromcad/dist/mmg-core.wasm) andout/mmg/dist/mmg.cjs(the MMG JS wrapper, fromcad/node_modules/@loumalouomega/mmg-wasm/dist/— KKSS ships nonode_modules, so the staged tree is the only runtime resolution left). Since cad 3.14.0 also checkout/cad-runtime/dist/draco/(web pair + Node wasm + licence, fromcad/dist/draco/) andout/renderer/dist/draco/(web pair for the interactive viewer, whose../dist/draco/lookup resolves underout/renderer/). Since mesh 5.5.0 also checkout/{analysisWorker,seriesScanWorker,oracleOpWorker,meshioWriteWorker}.js(the cancellable worker fleet, frommesh/dist/, same beside-main.jscontract as MMG/streamline), since mesh 5.10.0out/meshioReadWorker.js(the meshio++ read worker —configureMeshioReadRunnerinmeshHost.tsis 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.0out/derivedWorker.js(the derived-mesh / partition worker —configureDerivedRunnerinmeshHost.tsis 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) plusTHIRD_PARTY_NOTICES.mdbeside 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, andKRATOS_VTK_WASM=skipis the explicit opt-out. Since cad 3.22.0 also check the three Manifold copies ofmanifold.wasm+ its Apache-2.0 licence (fromcad/dist/manifold/):out/cad-runtime/dist/manifold/(the kernel worker'smanifoldNode.tsreads it from beside its own bundle, and the headlessrender_snapshotpage atout/cad-runtime/media/resolves../dist/manifold/),out/renderer/dist/manifold/(the interactive viewer'smanifoldBrowser.ts, same../dist/contract as Draco), andout/manifold/(manifoldNode.ts's__dirnamefallback for the OCCT-less compute worker, whose__dirnameisout/). Since cad 3.26.0 also check the OpenSCAD port:out/cad-runtime/dist/openscad-core.wasm(the 24 MB binary, fromcad/dist/openscad-core.wasm, passed explicitly aswasmBinaryand separately replaceable) plus the glue treesout/openscad/dist/(interactive worker,__dirname=out/) andout/cad-runtime/openscad/dist/(MCP kernel worker) — both copied fromcad/node_modules/@loumalouomega/openscad-wasm/dist/minus the bundledopenscad-core.wasmcopy — withLICENSE/NOTICE/THIRD_PARTY_NOTICES.mdbeside the interactive tree (GPL-3.0-or-later attribution, same as cad's VSIX carve-outs). - Compare
tools/webviewMarkup.ts's mesh body element for element withmesh/src/webviewChrome.ts'sbuildPreviewHtml, using its exported fragments. Preserve the KKSS stylesheets, shim-before-bundle order and deliberate omission ofstartEmpty— and of the renderer attributes, which the host injects per load. The build's theme guard checks new--vscode-*and--ds-*uses. - 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. - Start the copied CAD and mesh MCP servers with the environment from
serverSpecs()inmcpManager.ts, list their actual tools and compare both missing and obsolete names againsttoolPolicy.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. - Run
npm run typecheck,npm test,npm run check:packaging,npm run smoke(underxvfb-run -aon headless Linux), andnpm run docs:build. Exercise the release's new operations with upstream fixtures and temporary outputs. Update user guides, README and project memory; regenerate screenshots withnpm run docs:screenshotsif viewer markup or visible behavior changes.test/settingsRegistry.test.tsfails until every contributed setting of both submodules is either a registry row with a matching type/enum/default or listed inNOT_APPLICABLEwith a reason — and a row only counts as wired if something reads it, which is why the renderer is injected byapp/main/mesh/rendererPage.tsrather than merely registered, and offered as aView ▸ 3D Rendererquick 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-valuedisabledValue) belong there rather than in the row.node tools/renderer-probe.mjsdrives 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) + asemvercompare; 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..debinstalls and the (unsigned) macOS builds get an "Open releases page" button instead, as does any runtime updater failure. - Both
semverandelectron-updaterare devDependencies bundled intoout/main.jsby esbuild — the package still ships nonode_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'sbase: '/KKSS/') in every asset and page link, and VitePress's client router resolves againstlocation.pathname— so the manual URL keeps the base (kkss://docs/KKSS/…,manual.ts'sMANUAL_ROOT_URL) and the protocol branch stripsSITE_BASEbefore mapping to disk (resolveDocsPath,app/main/services/docsServe.ts, pinned bytest/docsServe.test.tsagainst path traversal). Change the docs base anddocsServe.ts'sSITE_BASEmoves with it; the test fails if the constant and a builtdist/index.htmldisagree. - The ~28 MB of downloadable examples stay a web concern.
copyArtifacts()stages everything butexamples/, and rewrites the built pages'<a href>example links to the published site's URLs (rewriteExampleLinks, the same pinned module) —manual.tsinterceptswill-navigateoffkkss://docsand opens those (and any outside URL) in the OS browser instead of replacing the manual. - A distinct origin. The manual lives on the
kkss://docshost label (neverkkss://app), so the site's scripts are notkkss://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:
| Tutorial | 1 thread | 2 threads | 4 threads | 16 threads |
|---|---|---|---|---|
| Structural | 531 | 473 | 440 | 589 |
| Fluid | 893 | 852 | 887 | 1,135 |
| Thermal | 77 | 78 | 79 | 143 |
| Potential flow | 84 | 87 | 87 | 106 |
| Shallow water | 86 | 87 | 90 | 151 |
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:
KKSS_RUN_TIER4_ACCEPTANCE=1 \
KKSS_TIER4_PYTHON="$PWD/node_modules/.cache/kkss-tier4/runtime/bin/python" \
npx vitest run test/tier4Acceptance.test.tsThe 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'sallowScriptsmust keep thenode-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-armfor 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-quittherefore awaitsTerminalService.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.tsbehindeditor: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 bindsMod-sfor 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__*):
| Server | Bundle / command | Placement contract |
|---|---|---|
cad-preview (83 tools) | out/cad-runtime/dist/mcp-server.js | beside 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:
awaitApprovalnever 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.tsdrops atoolCallwith no matching result, so a silent denial would vanish from the next request and the model would re-emit the same call, burning one ofMAX_ITERATIONS. - A pending approval is never persisted. It rides
ChatToWebview'sstate.pendingApprovaland is replayed onchatReady, 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 optionalapprovalfield on thetoolCallentry, which needed noCHAT_STORE_VERSIONbump (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/mcpreserved, because classification is keyed by the full name and a user server calledcadwould inheritcad__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 andmakeClassifier. Marks are{ [id]: { fingerprint, tools } }underassistantMcpToolMarks, 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 fileassistantFiles.readAssistantTreereads 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 onlymcp.jsonhashes 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 (stateinvalid) 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 behindStoreDeps(state, secrets,projectRoot.explicit(), host env). It reads the project file defensively (regular file, realpath inside the root, ≤ 64 KB), turns definitions intoServerSpecs andChatMcpServerRows, and owns every mutation (save,remove,setEnabled,setMark,trustProject,revokeProject). Env values and tokens go throughchat/secrets.tsunderassistantMcp.<id>.env.<NAME>/.tokenand are resolved lazily at connect time (ServerSpec.resolveEnv/http.token), so no status, row or IPC message ever carries one. A project spec carriespreflight(), 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—ServerKeyis now astring;ServerSpecgainssource,http,resolveEnv,blocked(disabled|untrusted),preflightandrevision.setUserServers(specs)reconciles only the non-built-in servers (an unchangedrevisionkeeps its connection, an edit reconnects, a removal closes; beforestart()it stays lazy),restartServer(key)generalisesrestartKratos, andtoolList(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 useStreamableHTTPClientTransportwithredirect: "error". A user tool whose namespaced name a provider would reject (^[a-zA-Z0-9_-]{1,64}$) is left out and counted inskippedTools. Built-in statuses stay byte-identical (nosource/transportfield). Resources and prompts of user servers are excluded from themcp__*tools and the meta server: those tools are classifiedread, so aggregating them would be an un-gated read path into an arbitrary server.- The gate.
ChatService.executeToolbuilds its classifier fromUserServerStore.classifier()and passes it togateFor(GateOptions.classify, defaultclassifyTool), so built-in names still resolve through the reviewed table and a user tool isunknown(⇒ ask) unless marked.toolPolicy.ts's built-in rows are untouched (test/chatToolPolicy.test.ts's pin moved only forapp__skill_load); a user tool is never offered the project-folder grant (noSCOPED_TOOL_PATH_ARGSrow) and never counted byreportUnclassified. The Claude CLI and Codex runtimes need no change: they expose whateverchatTools()returns and keep their own plugin/MCP systems off. Meta server:metaToolAllowedalready treats an unclassified name as allowed only underall, or underreadListedwhen listed inmetaServerAllowedTools— exactly the contract for user tools; marks deliberately do not widen it (test/metaServer.test.ts). - IPC.
ChatToHostgainedmcpRefresh/mcpSaveServer/mcpRemoveServer/mcpSetEnabled/mcpRestart/mcpToolsRequest/mcpSetMark/mcpTrustProject/mcpRevokeProject/mcpReloadProject;ChatToWebviewgainedmcpManager(rows + project state +managed, replayed onchatReady, sent before an error),mcpTools,mcpSaved,mcpErrorandmcpOpen. They are handled inChatService.handleMcp, behind the same sender check as every chat message, and every payload is revalidated there. A managedassistantMcp*key makes every edit answermcpError: 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 atMAX_INSTRUCTIONS_CHARS= 6000 with atruncatedflag),skills/<name>/SKILL.md(a---header read by a deliberately tinykey: valueparser — no YAML engine, so no tags, anchors or expansion;descriptionrequired and one line,nameoptional but must equal the folder) andprompts/<name>.md.mergeDefinitionslets the user's win a name collision (the MCP-server-id rule).definitionsSuffixrenders the context block;skillCatalogbounds it (MAX_CATALOG_CHARS) and counts what it leaves out;expandTemplateimplements/name [args]($ARGUMENTS,//escape, only a defined name expands so paths pass through,MAX_EXPANDED_CHARS).chat/assistantDefsStore.ts— the glue (injecteduserDir,enabled(),trustedProjectFiles()):snapshot()reads<userData>/assistantand, viaUserServerStore.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 oneapp__skill_loadtool, registered next to the workflow tools inindex.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 throughsendRefused. It takes onesnapshot().suffixper user turn (turnDefsSuffix) andcontextSuffix()appends it after the workspace line, so the text is identical across every iteration and compaction retry. It rides the request copy only — neverSYSTEM_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 (theapp__skill_loadcapability);app__skill_loadis areadrow intoolPolicy.ts(297 rows).templateHintsanswers the composer's suggestion strip (length-capped, only for text matching^/[a-z0-9-]*$);assistantDefscarries 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_loadsays so). The folder button opens<userData>/assistantafter 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.tsis 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, withresult.textcapped at 8 KB), and the decision table —judge(one run → pass / deny / ask / failed),stopsandfold(many runs → oneHookOutcome). Exit 0 is no opinion, 2 denies (PreToolUse,UserPromptSubmitonly), 3 asks (PreToolUseonly); a failure is a deny only forPreToolUse. There is deliberately no "allow" outcome anywhere in the type.hookRunner.tsruns one child (HookExecutoris the test seam):spawnwithdetachedon POSIX so the whole group is killed (taskkill /T /Fon Windows), a timeout timer, an abort listener, output capped at 8 KB per stream, and astdinerror 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 sokillAll()works without a signal.hooksStore.ts'sHookEngineis theAssistantDefsStoretwin:snapshot()readsreadAssistantTree(userDir, null)plusUserServerStore.trustedProjectFiles()(a fresh, hash-verified read, so an edit after trust stops a project hook at once and firesonChange),run()executes the matching hooks in order with the SDK's minimal environment (getDefaultEnvironment()plusKKSS_HOOK_EVENT/KKSS_PROJECT_ROOT, injected fromindex.ts— neverprocess.env), andsummary()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 ahooks.jsonexists (the shared reader fails the whole folder on one unsafe file)PreToolUseis denied rather than run without the user's policy.assistantFiles.readAssistantTreelistshooks.jsonbesidemcp.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.executeToolrunsPreToolUseafter thetoolCallentry is appended and beforegateFor. A deny sets the optionalhookfield on the entry (setHook, thesetApprovaltwin, pushed ashookNote), appends thetoolResult ok:falsethe gate rule requires, and returns; an ask is OR-ed with the gate's answer, never insidegateFor, sotoolPolicy.tsand its key-set pin are untouched, and the prompt carriesaccess: "hook"(for a read tool) plushook: {name, reason}.PostToolUseruns after the tool returned; an opted-in note is appended to the result throughwithHookNotesbefore thetoolResultentry is written, so the sidebar, the transcript and the model all see the same labelled text.UserPromptSubmitruns inrun()after template expansion and before the user entry is appended; becauserun()is synchronous up tobusy = true, apromptHookingflag joins the busy guard andsettleTurn(), and the turn'sAbortControlleris created before the hook so Stop and conversation switches can interrupt it. A refusal issendRefusedwith reasonhook, handing the draft back.TurnEndis fire-and-forget from thefinally(outcomedone/error/stopped).flushSync()anddispose()callkillAll():flushSyncruns atwill-quitbefore 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 throughexecuteTool. - Persistence.
hook?: {name, decision: "denied" | "ask", reason?}is an optionaltoolCallfield, parsed bytranscriptStoreCore.ts'sparseHookNote(a malformed value costs the note, never the entry) — noCHAT_STORE_VERSIONbump, theapprovalprecedent. The hook list and its parse errors travel on the existingassistantDefsmessage. - Meta server.
buildMetaServer(hub, version, policy, hooks?)runsPreToolUseaftermetaToolAllowedand beforecallToolRaw, andPostToolUseafter;MetaHooksis the one-method slice of the engine. There is nobody to prompt, so an ask is a deny there. Home's and Jobs'callToolRawcalls anddryRunTooldo 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(realnode -echildren: 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 oftest/chatService.test.ts,test/metaServer.test.ts, and the transcript round-trip intest/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.mshmust not becomecase.msh). One directory holds exactly one document plus its sidecars and declared companions — that is what makes cad's${modelPath}.parts.jsonsiblings land correctly with nocadHost.tschange. allowRoot()is untouched.CadHost.openPathalready allow-listspath.dirname(fsPath), which for a staged file is its staging directory. Allow-listingcloud-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'ssaveMesh()overwrites the document in place with a barefs.promises.writeFileinside 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 toservices/watcher.ts) covers cad's ten sidecar writers, mesh's in-place save, an export aimed back into the directory andEditorServiceat once. It isdepth: 0unless the format declares companions. - Companions are an explicit allow-list.
cachePathCore.companionsFornames them: XDMF/XMF → sibling<stem>.h5;.foam→ theconstant/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/.downloadat any segment) decides what a nested path may upload.companions.tsis the transfer half:downloadCompanions(called fromStagingCache.stage; any failure fails the open, since a.foamwithout its mesh is an empty case) andRemoteFolders(per-pass cache over the providers' idempotent, race-tolerantensureFolder; a file where a folder is needed is aconflict, never replaced). Uploads follow the document's conflict rule per file, with baselines in the optionalCloudEntry.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, Dropboxrev, GraphcTag— nevereTag, 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 (preconditiononCloudFile) because Graph'sif-matchaccepts onlyeTag. 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 socase.post.mshstays routable. The remote's current revision is then adopted as the new baseline, or every subsequent tick would conflict forever. cad-preview-macros.jsonandcad-preview-mesh-presets.jsonare 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-quitcannot await an HTTPS upload or a shell's exit, sobefore-quitcallspreventDefault()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 staysdirty: truein the manifest and is reported on the next launch.will-quitonly callscloud.flushSync(), the manifestJsonStore's last synchronous write. - OAuth is bring-your-own-client. No KKSS client id is baked in.
oauth.tsreusesmetaServer.ts's listen/error/Host-check shape for a one-shot127.0.0.1listener, with PKCE S256 and astatecheck; tokens go throughchat/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, GraphPUT .../content, Dropboxfiles/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, souploadChunkedrejectssize === 0outright 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:httpandnode:stream, plus thechokidarKKSS already ships. The official SDKs are license-compatible but each drags a large tree intoout/main.jsfor 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>;isValidHostrejects a leading-(option injection) and every path goes throughshQuote. The revision ismtime:sizeand 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 iscp -p(keeps the mode) +cat > tmp+mv -fover the original. - Terminal.
TerminalService.openRemote(host)sets a one-shot target consumed by the nextspawn(), which runsssh -t <host>through node-pty; shutdown is unchanged. - Kratos MCP.
McpManager's fourth constructor argument (kratosRemote, fromsettings/kratosEnv.ts'skratosRemoteHost()) replaces the Kratos spec withssh … uvx --with 'mcp<2' kratos-mcp-server@<pin>and skips both the local uv runtime and the local Kratos env delta.classifyStartupFailurewords ssh errors (Permission denied, host-key, DNS, missinguvx) asunknownso the banner never offers a local uv install for a remote problem. The chat context suffix (ChatDeps.currentFiles().kratosHost, and thestaging copy of host:/pathmark) carries the split between remote Kratos paths and local staging paths — notSYSTEM_PROMPT. - MPI runner (single node, guided solves).
workflows/mpiReceipt.tsis the pure contract:mpiResources(ranks × threads per rank as the verified slot total),mpiPlanArgs(frozen intotask.argsso the plan fingerprint covers ranks and launcher),parseMpiReceipt(owner/request mismatch throws, bad shape isundefined),reconcileMpiandmpiRunScript. The script ownsreceipt.json: it runs the launcher withOMP_NUM_THREADSper rank from<run>/input, wraps every rank in a tinyshstep that recordsranks/<rank>.pid(rank read fromOMPI_COMM_WORLD_RANK/PMI_RANK/PMIX_RANK/SLURM_PROCID; a launcher exporting none fails the run rather than being guessed at) and writeseffectiveRanksinto the terminal receipt.reconcileMpireadsuncertainfor 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 theRunnerfor a solve task frozen withmpiRanks: every effect goes through anSshRunner, sorunLocalShserves this machine and the ssh runner servesremote.kratosHostwith the same code.dispatchrewrites the case'sProjectParameters.jsonthrough the pureworkflows/mpiCase.ts(problem_data.parallel_type: "MPI"; Kratos partitions in memory at import, so no pre-partitioned.mdpaexists) on the uploaded copy only, and refuses —failed, before anything runs — a case with a serial-only linear solver instead of swapping it.validateprobes the launcher, theKratosMultiphysics.mpi/TrilinosApplication/MetisApplicationimports and the CPU budget.lookupadds a per-rank pid check while the launcher is alive and reusesfetchRunArtifacts(checksum-verified downloads, shared withRemoteRunner). The launch path keeps the queue rules: an unknown launch outcome throws (souncertain, never retried).WorkflowService.solveRunnerpicks the MPI runner before the remote one;kratos.mpiRanks(0 = off) is the only switch, the preview refuses whenEnvironmentReport.manual.capabilities.mpiis false, andruntimeFingerprintincludesmpiRanksonly when set so threaded plans keep their revisions.EnvironmentReportreportsmpi: trueonly when the probe saw a launcher (mpirun/mpiexec/srun,KRATOS_MPI_LAUNCHERfirst), a POSIX shell and all three MPI modules. Limits: one node (pids are local to the launcher), theProjectParametersrewrite is verified against fixtures and stand-in launchers only, and no live MPI-enabled Kratos run has been recorded; seedoc/roadmap.mditem 4. Tests:mpiReceipt.test.ts,mpiCase.test.ts,mpiRunner.test.ts(killed-rank and short-rank cases), and the MPI cases inworkflows.test.ts. - Guided solves. With a remote host set,
WorkflowService.remoteSolver()sends solve tasks toworkflows/remoteRunner.ts(aRunnerover an injectedSshRunner). It uploads the run'sinput/plus a generatedrun.sh(sshCore.remoteRunScript) tokkss-runs/<study>-<run>, launches itsetsid nohup, and reads the script-ownedreceipt.jsonforlookup/cancel(pid liveness check; owner/request mismatch throws; group TERM with the script trapping it so the receipt still lands). Process exit 0 issucceededwith a "convergence not established" note. A failure before the launch command isfailed; an unknown launch outcome throws so the queue recordsuncertainand never retries. On a terminal receipt it downloads the run's outputs (files newer thanrun.sh, plusstdout.log) into the local run directory with sha256 verification, so review and discovery see ordinary local files; a failed download readsuncertainand retries.validateprobes the host (python3, importable Kratos,nproc). Home's environment report is host-aware too:environment.ts'scheckRemoteEnvironmentruns the samePROBE_SCRIPTthroughprobePythonover the injectedSshRunner(so applications, Kratos version and thread-control support are the host's), takes the CPU count fromnproc, createskkss-runsto prove the run directory is writable, and reports the local uv tool runtime as unused; the report carrieshost, andWorkflowServicefolds the host into every environment cache key so clearing or changingremote.kratosHostcan never serve a stale report. Mesh/generate tasks remain local by design.remoteRunner.test.tsruns the real script in a localsh(including a relative run directory — the script resolves it to an absolute path before changing intoinput/, otherwise the log and terminal receipt land in the wrong place), andworkflows.test.tsdrives a queued solve through the service, queue and runner to checksum-verified downloaded outputs. - Tests:
sshCore,sshProviderandsshRoundTriprun the real generated commands in a localsh(real quoting,ls/stat/cat, atomic upload, staging, write-back and conflict copy);sshStaging,terminalService,mcpRemoteKratosandkratosRuntimecover the shared plumbing;remoteEnvironmentand the remote case inworkflows.test.tscover the host-aware report, andtools/e2e/remote-env.mjsdrives it in the real app through a fakesshandpython3onPATH.test/sshdLive.test.tsruns the realspawnSsh/SshProvider/RemoteRunneragainst an actual sshd and is skipped unlessKKSS_SSHD_HOSTnames 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:
- 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.
- Connect… — the browser opens, consent returns to the loopback listener, and the status row shows the account.
- File ▸ Open from Cloud… → open a
.stp. Confirm the tab shows ☁ and the model renders. - Edit something, wait ~5 s, and confirm the sidecars appear beside the model remotely.
- Change the file from the provider's web UI, edit locally again, and confirm a
(conflict …)copy appears and the local file is untouched. - Upload something over 4 MB (Graph) / 150 MB (Dropbox) to exercise the chunked path.
- 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'sworkspace.getConfiguration(section).get(key, default)looks upsection.key. Sections may be dotted, as inkratos.flowgraph. A mapped key is served from the stateStore; anything else returns the caller's default. That is how everykratos.*setting the unmodified mesh providers read works, with no submodule change. The shim also implementsupdate()andonDidChangeConfiguration. - The Settings page.
app/renderer/settings/runs in its own singletonBrowserWindow, owned byservices/settings/settingsWindow.ts, withsettings:toHost/settings:toWebviewguarded by sender.- Main sends the schema once and then a
rowssnapshot on every stateStore change. - Every write is validated by the registry's
toStoredbefore 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'sconfirmApprovalOff). - Secrets are write-only. The renderer submits a new value but only ever receives
isSet.
- Main sends the schema once and then a
- The native menu.
menu.tskeeps 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 plusdata-vscode-theme-kindon<html>and<body>, and sets the UI font as CSSOM custom properties. - Live changes. Main re-broadcasts to every
webContentswhen a relevant key or the OS theme changes. - Palettes.
vscode-vars.csshas a:root(Dark+) block plus one block per other kind.check-theme-vars.mjsfails the build if a block does not redefine exactly:root's colour variables. - CAD viewer. It restyles itself: its
viewer.csskeys 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-selectchange on each appearance change while it isauto. - Editor and terminal. They read the same snapshot for their own options: CodeMirror through a
Compartment, xterm throughterm.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 windowmessageevent.app/main/vscodeShim.ts— a minimalvscodemodule. Itsworkspace.workspaceFoldersis a getter backed by the explicit project folder (via the__configureVscodeShimhooks object): that is what makesptController.discoverExternal()scan<root>/.kratos/problemtypes, so a project-local problemtype appears in the Problemtype list — impossible while this was permanentlyundefined. Explicit-only is a safety decision, not a style one:discoverExternalexecutes what it finds (sandboxed in anode:vmwith norequire/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 intoout/) 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/showTextDocumentrouted to the app's own text-editor screen, andenv.asExternalUrias an identity passthrough since there is no Remote-SSH/Codespaces tunnel) that esbuild aliases in place of the real API, lettingmesh/src/{mdpaEditorProvider,vtkEditorProvider,meshExport, opHistory,flowgraphController,ptController,runManager,recentMeshes,previewHtml,meshDocument}.tsrun 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 ofcad/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). ItsreadOcctSourceis the single choke point every OCCT path reads through, so a.scadis converted to.csgby the user-installedopenscadbinary once and nothing downstream ever sees format"scad". Deliberately not ported: SpaceMouse (it needsnode-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'sgetHtml/getNonce(KKSS generates its page withtools/gen-webview-html.mjs, whose CSP allow-lists thekkss:scheme instead of a nonce) and itsregisterCommands(KKSS's command surface isapp/main/menu.ts, and its What's New lives inapp/main/services/whatsNew.ts). ItsCadHostHooks.onMeshExportedfires after a meshing-panel export writes a file (now carrying both the exported path and the source CAD path);app/main/index.tswires it to refresh a matching clean mesh tab or create a new one (gated bymodeForFile) 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.jsonstructure into the target's<stem>.kratosview.json. Layer sync is bidirectional (app/main/services/layerSync.ts, pure and unit-tested): shared.stl/.obj/.plyfiles 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.tsalso ports the layers session (currentLayers+ never-recycledlayer-Ncounter,layersChangedautosave,readyhydration, rebind across op changes), the drawing-export layer filter (pickLayersFilter: All / Visible / Named, B-rep only), and the OpenSCAD backend/library-path settings throughreadOcctSource;remeshMesharrives via the existingmeshioServiceworker module plus a typed client binding. Mesh-source meshing fallback lives inapp/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’sbakeMeshEditsincadCompute.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-repgeometrypost is the provider's full message —autoFit,opOutcomes,guideIdsandopBucketsbeside the tessellation. The kernel replays only the unbaked tail, soapp/main/cadOpOutcomes.ts'srebaseOpOutcomes(pure,test/cadOpOutcomes.test.ts) pads the baked prefix as applied and shifts outcomes and buckets bybakedThrough;ready'sloadModel(false)marks a genuine open (autoFit: falseforces a re-frame) and an edit-driven rebuild keeps the default. File ▸ Save bakes (CadHost.saveDocument, the port of the provider'sbakeTailToSource/bakeMeshToSource): the ordering — confirm, one-deep.bak, temp sibling + rename, then the watermark, with the source rolled back if the watermark write fails — lives inapp/main/cadSaveInPlace.ts, Electron-free with every side effect injected (test/cadSaveInPlace.test.tsdrives it against an in-memory filesystem that can fail any call). Two deliberate departures from the provider: mesh bytes come from the compute worker'sbakeMeshEditsrather than the webview'sexportMeshround trip (it needs no WebGL scene and keeps edit replay in the worker), and the save-time Part/annotation/layer rebind (rebindPartsAcrossSave, bound incadComputeClient.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 andcad__save_model's full-history argument double-applies it on every save after the first.CadHostHooks.onDirtyChangeddrives the tab dot (syncTabsreportshost.isDirty()for both modes) andconfirmSaveInPlaceis the modal;index.ts'sconfirmDiscardTabserves 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 itskkss-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 runcad__save_modelon 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 ofstateStore.ts(the same split aschat/secretCodec.tsunderchat/secrets.ts, and unit-tested bytest/stateStore.test.ts). It is instantiated per file —state.jsonplus one instance per chat conversation and one for their index — so each file gets its own writer chain andwill-quitflushes each of them. Every write goes throughapp/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 concurrentupdate()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 samestate.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-forgetvoid stateStore.update(...)callers (every Settings menu click, the zoom picker, the mesh Memento) want.will-quitcannot await, so it callsflushSync()— 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 bytest/atomicWrite.test.ts) — the temp-file + fsync + rename primitive, extracted fromjsonStore.tsso 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>.tmpand the secondrenamewould fail with ENOENT. Two such overlaps exist incadHost.ts—flushSidecars()clears the seven debounce timers but cannot cancel one that has already fired, andcad-preview-macros.json/cad-preview-mesh-presets.jsonare 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.tmpname — 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 inapp/main/services/sidecarSuffixes.ts, imported by bothcadHost.tsand the cloud layer so the two lists cannot drift.app/main/services/recentFiles.ts(+ the purerecentFilesCore.ts, tested bytest/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 threehost.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 acloudref 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 ownRecentMeshStorekeeps running — both providers require it — but no longer drives any UI.recentKey/recentLabel/recentDescriptionare reused frommesh/src/recentMeshesCore.ts, which is vscode-free; itsrecordRecent/parseRecentListare not, because they would silently drop themodefield.app/main/services/projectRoot.ts(+ the pureprojectRootCore.ts, tested bytest/projectRoot.test.ts) — the project folder. Two levels: explicit (chosen via File ▸ Open Folder…, persisted underprojectRoot) and effective (explicit, else the focused document's directory). Consumers useeffective(), so with no explicit root every one of them behaves exactly as it did before the concept existed. Onlyexplicit()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 puresessionCore.ts, tested bytest/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 fromsyncTabs()/setScreen()/the panel toggles — so a SIGKILL still leaves a usable session — and once more onwill-quitbeforestateStore.flushSync(), since that call stops the store and would swallow a later write. Restoring prunes vanished paths first, opens each file withopenPath()(append, no re-routing, no recording), closes the blank starter tab, then restores screen and panels. Gated byKKSS_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 soSESSION_VERSIONstays 1, filled fromCloudService.refFor) because eviction deletes the manifest entry with the file and the staging path is a one-way hash.planRestore(the pure successor ofpruneSessionfor restore;loadSession()now returns itsRestorePlan) keeps such a file as a pending slot, restore creates its placeholder tab synchronously, andrestoreCloudTabsinindex.tsfills it afterwards throughdownloadCloud(shared withopenCloudFile) under the policy inservices/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,openPathrather thanopenFile, no recents.tools/e2e/cloud.mjs'scloud-restore-evicteddrives the real round trip.kkss://andkkss-file://schemes — replacements forasWebviewUri/localResourceRoots(app assets and allow-listed user files respectively). Externalkkss://open?file=…links are validated byapp/main/launch.tsand enter the same deferred queue as Finder and second-instance opens. An existing file the router cannot route is not queued:unsupportedLaunchFile(s)letsindex.ts'sreportUnsupportedanswer it (and the in-appopenFile()path) with one warning toast carrying a Supported formats action —toast(kind, text, action)inservices/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.shellToastdefers it until the shell is up. Native associations and Linux MIME entries are checked against the CAD and mesh registries bynpm 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'socctService,gmshService,massProperties,entityFacts,meshioService,meshioRegionParts— CPU-only but pulls in Three.js —hitTestService,meshHeal,primitiveReport,meshRegionFit,primitiveWrite,svgSilhouetteHost,modelDiffHostandstepPartsService,gltfDecode(cad 3.14.0's Draco/meshopt expansion), plus KKSS's owncadBRepCache), and throws at startup if two export the same name; the client re-exposes each with the submodule's own signature viaParameters<typeof …>, so a changed signature is a type error rather than a runtime surprise. Pure text helpers (stepUnits/igesUnitsunit 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 fromstart/success/failureevents around every call andresetwhen the worker dies, translating KKSS's two differently-spelled RPC names (loadBRepCachedInWorker,releaseBRepCache) throughKKSS_FN_ALIAS(resolveAssemblyTreeForDocumentanddecodeGltfGeometrymatch their pipeline keys exactly, so they need no alias);cadHost.tsfans changes out askernelStatusto every open CAD tab, and also postsdocumentInfo(the menubar chip — see Pre-Processing mode).- CAD-Preview 3.4 interactive additions —
CadHosthandlesmeshSweepRequestwith the sharedrunMeshSweeploop, document-owned cancellation, optional folder selection and.mshwrites;computeHoleTableis exposed through the existing mass-properties worker module forholeTableRequest. 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'sloadBRepCachedreuses 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 plainBRepResultcrosses the RPC. Entries are keyed by a stable per-tab session id, so every open cad tab gets its own slot;releaseBRepCacheis the counterpart of the provider'sonDidDisposeteardown and is called fromCadHost.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/wasmin the cad worker.cad/src/meshioService.tsloads meshio++ with a bareawait import(...)and no directory fallback (unlike mesh's own loader, which ends at__dirname/meshio), which would beERR_MODULE_NOT_FOUNDin a packaged install where KKSS ships nonode_modules. The shim resolves the copiedout/meshio/tree and passes a name-awarelocateFile. It must not userequire.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 mirrorwebviewChrome.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, viapreviewHtml.ts; it can't simply be called, because KKSS links two extra stylesheets and must loadshim.jsbefore the bundle), and links three stylesheets in order: the submodule'sdesign-system.css(the--ds-*token layerstyle.cssbuilds on),style.css, thenapp/renderer/theme/mesh-overrides.css.app/renderer/theme/mesh-overrides.css— KKSS-only overrides for the mesh webview. It hides#file-menuand#theme-select: mesh 3.0.0 put the viewer's File menu and scene-theme picker in an in-flow#menubarstrip, both of which the native menu already owns. The markup is still emitted (webview/main.tslooks 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 thedocumentInfomessage), and the page now ends in a#statusbar(engine activity fromengineStatus, model counts, timeline frame, last Inspect pick). Both messages rideMeshHost's unfilteredpostMessagerelay — no KKSS-side handling — so a mesh bump only has to keepmeshBody()mirroringbuildPreviewHtml(the status bar isSTATUSBAR_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 visibleView ▾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.mjsfails the build if a submodule update uses one that is missing, and likewise for a--ds-*tokenstyle.cssuses butdesign-system.cssdoesn't define. The guard also scans every stylesheet underapp/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.cssholds 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-visiblering. Every page links it right aftervscode-vars.cssand 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'sglyph(id, size?)re-exports cad's line-glyph set (cad/src/uiGlyphs.ts) plus KKSS extras (x,menu,send,history, …); add a missing icon toKKSS_GLYPHSthere. The generated TikZshell/shellIcons.tsstill supplies the mode/home/open/edit/terminal/chat/settings/help icons. Strict-CSP pages (style-src kkss:) must not use inlinestyle=attributes — toggle thehiddenattribute instead.
Building from source
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:
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:
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.
npm run build # once, so out/ is complete
env -u ELECTRON_RUN_AS_NODE xvfb-run -a npm run docs:screenshots # headless LinuxPNGs 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.
| Command | Produces |
|---|---|
npm run tutorials:manual | Geometry, 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:gallery | Field 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:animations | The animated GIFs listed in the catalog. |
npm run tutorials:docs | Renders 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-tunnel | The six virtual wind tunnel wizard steps, shot in the mesh viewer on the published body. |
node tools/tutorials/manual.mjs --fe-mesh | The CAD window after FE Mesh Generate for each case (tutorial-<id>-fe-mesh.png). |
node tools/tutorials/manual.mjs --fit-cad | The 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:
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:galleryWithout 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— monochromecurrentColorshell 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 byelectron-builder.ymland 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:
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.