v3.5.0
release: v3.5.0 (#88)
Slothlet v3.5.0 Changelog
Release Date: May 2026
Release Type: Minor
Branch: release/3.5.0
Overview
Version 3.5.0 ships four changes:
-
Bug fix — TypeScript runtime imports:
.tsand.mtsmodules can now import bare specifiers (e.g.import { self } from "@cldmv/slothlet/runtime") — the form needed to reach the runtime singletons and other package imports. Prior versions served transpiled output fromdata:URLs, which Node's ESM resolver cannot anchor bare specifiers against, so cross-module imports inside TypeScript silently failed even though the same imports worked from.mjs. Relative imports between user TS modules (import './sibling.ts') remain unsupported through this cache path — load each module separately and wire them viaself.*. -
New feature —
slothlet typegen: a CLI and programmatic API that generate a.d.tsfile describing a Slothlet API directory. Designed for fast-mode users who want editor-time type-checking and autocomplete onself.*without opting into strict mode at runtime. The generator runs on demand (e.g. frompredev/prebuildscripts) — runtime is unchanged, and the generated file is the user's to commit, gitignore, ship, or package separately. -
New feature —
self.X = …actually works: runtime assignments toselfwere being silently dropped (proxy default-set onto an empty literal target). They now persist for the instance's lifetime, are validated against the writer's owned namespace, and get aUnifiedWrapperwhen the value is callable / object-shaped. -
Bug fixes — reload,
.run()/.scope()isolation, and API cache cleanup: a reload now fully re-executes its operation history (a replayed remove does the same cleanup a live remove does);full-isolation.run()/.scope()correctly sandboxesself(it previously used an unsounddeepClone); and the per-module API cache is invalidated when a module is removed, so stale entries no longer accumulate. A consequence of correct reload semantics: runtimeself.X = …writes do not survive a reload.
No breaking changes to configuration or API surface. Behavior change: runtime self.X = … writes are no longer carried across a reload — a reload rebuilds the instance from disk and operation history.
🐛 Bug Fixes
.ts / .mts modules can now import from @cldmv/slothlet/runtime
Before: TypeScript modules that imported self, context, or instanceID from @cldmv/slothlet/runtime (or imported any other bare specifier) failed at load time:
Cannot find package '@cldmv/slothlet' imported from data:text/javascript;...
The .mjs loader path used pathToFileURL(filePath) so Node could anchor bare-specifier resolution at a real file:// URL. The TypeScript loader path used createDataUrl(transformedCode) instead — Node's ESM resolver cannot anchor bare specifiers against a data: URL base.
After: .ts and .mts modules can use bare-specifier imports the same as .mjs modules — self, context, instanceID, and any other bare-specifier (package) import resolves normally. Relative imports between user TS modules are out of scope for this cache path; rely on slothlet's per-file loading and self.* wiring instead.
// api/utils.ts
import { self, context, instanceID } from "@cldmv/slothlet/runtime";
export function describe(): string {
return `${instanceID}/${context?.requestId ?? "anon"} → ${self.math.add(1, 1)}`;
}Root cause
src/lib/processors/loader.mjs handled .ts / .mts files via:
const transformedCode = await transformTypeScript(filePath, { ... });
moduleUrl = createDataUrl(transformedCode);
const module = await import(moduleUrl);The resulting data:text/javascript;... URL has no filesystem location for Node's ESM resolver to walk parent node_modules from, and is not eligible for self-reference resolution. Bare specifiers inside the transpiled code therefore could not be resolved.
What changed
A new helper writeTransformedToCache(originalPath, code, instanceID) in src/lib/processors/typescript.mjs writes the transpiled output to:
<projectRoot>/.slothlet-cache/<pid>-<instanceID>/<contentHash>.mjs
and returns its pathToFileURL URL. The loader now appends the same ?slothlet_instance=…&module=…&_reload=… query suffix used by the .mjs branch and imports against that URL — so Node sees a real file:// URL inside the project tree and bare-specifier resolution works exactly like for .mjs.
The cache location is deliberately outside node_modules/ because Node's ESM READ_PACKAGE_SCOPE halts at any node_modules/ segment when looking for the closest enclosing package.json. Placing the cache outside that boundary keeps self-reference resolution working in monorepo / dev-checkout scenarios where no node_modules/@cldmv/slothlet/ exists.
Cache lifecycle
- Per-instance cleanup:
api.slothlet.shutdown()removes the current instance's cache directory. - Orphan sweep: On the first TS load per project root per process, slothlet scans
.slothlet-cache/and removes sibling directories whose<pid>prefix no longer matches a live process. Liveness is probed passively viaprocess.kill(pid, 0)(signal 0 — nothing is killed; ESRCH means the process is gone, EPERM means it's alive but unsignalable). This guarantees the cache stays bounded even when processes exit without callingshutdown()(SIGKILL, OOM, hard crash, forgotten shutdown). - Content-hashed filenames: unchanged sources reuse the same cache file; reloads with the same content are write-free.
- Gitignore: add
.slothlet-cache/to.gitignore. Slothlet's own.gitignorealready excludes it.
🚀 New Features
slothlet typegen — on-demand TypeScript declaration generator
Slothlet now ships a typegen subcommand and a matching programmatic API for generating a .d.ts file that describes a Slothlet API directory. The generated declaration includes a top-level interface and a declare const self: <Interface>, which gives .ts modules autocomplete and type-checking on self.* calls without forcing the user into strict mode at runtime.
Why this exists. Fast mode (typescript: "fast") uses esbuild and skips type-checking — that's the whole point. But editor diagnostics like Property 'foo' does not exist on type '{}' still bother users browsing their own .ts modules, because nothing tells TypeScript what shape self has. Until now, the only way to fix that was to opt into strict mode (which auto-generates types as a side-effect of tsc) or hand-maintain a .d.ts file. slothlet typegen is a third path: run it on demand, commit the result (or gitignore it and regenerate in CI), and your editor sees the same shape Slothlet builds at runtime.
CLI usage
# Positional
npx slothlet typegen ./api ./types/api.d.ts MyApi
# Flag form (long or short)
npx slothlet typegen --dir ./api --output ./types/api.d.ts --interface-name MyApi
npx slothlet typegen -d ./api -o ./types/api.d.ts -n MyApi
# package.json fallback
# { "slothlet": { "typegen": { "dir": "./api", "output": "./types/api.d.ts", "interfaceName": "MyApi" } } }
npx slothlet typegenResolution order per option: flag → positional → package.json.slothlet.typegen. Missing fields fall through to the next source; if all three are still unset after that walk, the CLI exits non-zero with a clear error.
Wire it into your project lifecycle however you want:
// package.json
{
"scripts": {
"predev": "slothlet typegen",
"prebuild": "slothlet typegen"
},
"slothlet": {
"typegen": {
"dir": "./api",
"output": "./types/api.d.ts",
"interfaceName": "MyApi"
}
}
}Programmatic usage
import { generateTypes } from "@cldmv/slothlet/typegen";
const { filePath } = await generateTypes({
dir: "./api",
output: "./types/api.d.ts",
interfaceName: "MyApi"
});
console.log(`Wrote ${filePath}`);Notes
- The generator loads the API in eager + fast TypeScript mode internally, walks the resulting structure, extracts type info from the source files via the TypeScript compiler API, writes the
.d.ts, and shuts the loaded instance down before returning. - Output shape mirrors what strict mode emits: a top-level
interface, adeclare const self: <Interface>, and JSDoc comments preserved from your sources. - Runtime behavior is unaffected:
slothlet()does not call the generator implicitly. You decide when to regenerate (e.g. on file change, in CI, in apredevscript) and what to do with the output (commit it, gitignore it, ship it as part of a separate types package).
self.X = … — runtime self-assignment now actually works
Previously, assigning to self from inside a Slothlet module — self.someValue = "hello" — was silently dropped. The runtime self proxy had no set trap, so JS default-set onto an empty {} literal target while reads routed through get to the live API, leaving the writer unable to even read back what they wrote.
This release adds a three-part fix:
-
Persistence: the runtime
selfproxies (dispatcher + async + live) now have asettrap that routes the assignment throughapiManager.setOwnedProperty(...). Writes land onslothlet.api[prop]and are visible fromself, from other modules, and from outside viaapi.X, for the lifetime of the instance. -
Owner-scoped writes: a module's writes are restricted to its own mount-point subtree. A module mounted at
lib.configcan writeself.lib.config.foo = …but notself.lib.ssh.foo = …. The owner root is looked up via the ownership handler (ownership.getModuleEndpoint(callerModuleID)). External code (nocurrentWrapperin ALS) and base-loaded modules effectively own the whole tree. Prototype-pollution path segments (__proto__,prototype,constructor) and reserved root names (slothlet,shutdown,destroy) are rejected. -
Wrap-on-set: when the assigned value is callable or object-shaped, it gets a
UnifiedWrapperconstructed around it via the same pathapi.slothlet.api.add()uses (setsapiPath,moduleID,isCallable,sourceFolder). Primitives stay as-is. The known limitation: hook / permission / lifecycle integration on these synthetic wrappers is incomplete and fullhook.on("after:X")interception isn't yet guaranteed — useapi.slothlet.api.add()for fully lifecycle-integrated mounts.
import { self } from "@cldmv/slothlet/runtime";
// Inside a Slothlet API module mounted at `lib.config`:
export function init() {
self.lib.config.computed = derive(); // ✓ allowed (owns lib.config.*)
self.somethingElse = "x"; // ✗ throws LOOSE_SET_NOT_OWNED
}The LOOSE_SET_NOT_OWNED and LOOSE_SET_RESERVED_KEY errors are translated across all 12 supported languages.
Reload behavior. A runtime self.X = … write persists for the instance's lifetime but does not survive a reload — a reload rebuilds the instance from disk and replays its add/remove operation history, and a runtime write is not part of that history. A write a module performs in its module-init body still reappears, because the module body re-executes during the rebuild.
Known scope gaps (for follow-up)
- Deep-path wrap-on-set: writes like
self.X.foo = …flow through the existingUnifiedWrapper.setTrap(usesObject.definePropertywithwritable: false), not throughsetOwnedProperty. The wrap-on-set + ownership-validation work currently applies only to top-level writes that fire the runtimeselfset trap. Deep-path integration requires modifying the wrapper setTrap (recursion-avoidance, materialization timing) and is deferred. - Hook integration on synthetic wrappers: see point 3 above.
Reload, .run()/.scope() isolation, and API cache cleanup
Three related defects where slothlet did not behave as designed:
-
Reload now fully re-executes its operation history. A reload replays the add/remove chain to rebuild the instance "as if built again up to this point". The replayed
removepreviously did a shallow tree prune — it removed the path fromapibut leftboundApi, the API cache entry, ownership records, and metadata behind. It now routes through the sameremoveApiComponentpath a live remove uses, so the rebuilt tree matches a clean build. -
The per-module API cache is invalidated on removal.
apiCacheManagercaches each module's build parameters for fast targeted reload. Removing a module by apiPath previously left its cache entry orphaned (only removal by moduleID cleaned it). Removal now sweeps cache entries whose mount endpoint no longer resolves in the live tree — by apiPath or moduleID, directly or via a reload's replayed remove. Partial removals are preserved (a module that still owns part of its subtree keeps its entry). -
full-isolation.run()/.scope()correctly sandboxesself.fullisolation previouslydeepClonedself; deep-cloning the liveboundApiProxy tree is unsound — it produced a non-proxy copy and broke concurrent live-mode.run().fullisolation now gives the scope a recursive, memoized copy-on-write view ofself: reads pass through to the live instance, writes (top-level and deep-path) land on a per-scope overlay discarded on exit.partialisolation is unchanged —selfis shared and writes persist, by design.isolationgovernsselfonly; context is isolated in both modes.
📚 Documentation Updates
docs/TYPESCRIPT.md— replaced the "self constant" section with a "Runtime Imports" section covering all three exports (self,context,instanceID) and noting the on-disk cache. Updated the Limitations section to describe the new cache directory and sweep behavior.docs/MODULE-STRUCTURE.md— added a "Runtime Imports from.ts/.mts" subsection to the TypeScript section.docs/CONTEXT-PROPAGATION.md— added a TypeScript module example alongside the existing ESM and CJS examples.docs/TYPESCRIPT.md— new "Generating Types for Fast Mode" section documentingslothlet typegen(CLI, programmatic, package.json fallback).README.md— promoted v3.5.0 to "Latest", expanded cross-module-access example to showinstanceID, called outslothlet typegenin the runtime/context feature list.
🧪 Test Coverage
New regression suites and fixtures:
api_tests/api_test_typescript_runtime/— fixture covering both extensions:foo.ts(sibling source),bar.ts(importsselfand calls foo), andbaz.mts(importsself+instanceIDand chains into bar). Proves bare-specifier resolution works from.ts, from.mts, and across.mts → .tscalls.tests/vitests/suites/typescript/typescript-runtime-self.test.vitest.mjs— exercises the regression in fast and eager modes for both.tsand.mts.tests/vitests/suites/typescript/typescript-cache-sweep.test.vitest.mjs— verifies orphan-PID dirs are removed while live-PID and unlabeled dirs are preserved.tests/vitests/suites/typescript/typescript-helpers-coverage.test.vitest.mjs— coverscreateDataUrl,findPackageRoot,writeTransformedToCache(cache-dedup and missing-root paths), and the sweep's PID classification (dead / live / EPERM).tests/vitests/suites/processors/loader-ts-cache-coverage.test.vitest.mjs— exercises the loader's#buildTypescriptModuleUrlacross initial load,api.add(..., { moduleID }), andapi.reload(apiPath)to cover both arms of themoduleIDandcacheBustquery-suffix branches.tests/vitests/suites/metadata/metadata-key-validation-coverage.test.vitest.mjs— covers existing prototype-pollution guards on themetadata.setpath.tests/vitests/suites/typegen/typegen-programmatic.test.vitest.mjs— exercisesgenerateTypes()happy-path and validation (dir/output/interfaceNamemissing or empty).tests/vitests/suites/typegen/typegen-cli.test.vitest.mjs— spawns the CLI and verifies subcommand dispatch,--help, positional + flag +package.jsonfallback resolution (in all three precedence orderings), and validation errors (missing options, unknown flag, flag-without-value, unparseable package.json).api_tests/api_test_self_assign/— fixture for owner-scoped write + scope-sandbox tests; exposeswriteUnderOwn,writeOutside,readSelf,introspectSelf, and more.tests/vitests/suites/runtime/self-assign-persist.test.vitest.mjs— primitive + function assignments persist and are visible throughselfandapi.*.tests/vitests/suites/runtime/self-assign-ownership.test.vitest.mjs— external bootstrap writes allowed; base-module writes allowed; api.add'd modules denied when writing outside their endpoint subtree.tests/vitests/suites/runtime/self-assign-wrap.test.vitest.mjs— callable / object values get a UnifiedWrapper (verified viaresolveWrapper); primitives stay verbatim.tests/vitests/suites/runtime/self-assign-prototype-pollution.test.vitest.mjs/self-assign-reserved-name.test.vitest.mjs—__proto__/prototype/constructorand reserved root names are rejected.tests/vitests/suites/runtime/self-assign-replay.test.vitest.mjs— a runtimeself.X = …write does not survive a full reload.tests/vitests/suites/runtime/scope-self-sandbox.test.vitest.mjs—full-isolation.run()/.scope()sandboxesself(copy-on-write);partialdoes not.tests/vitests/suites/api-manager/api-cache-invalidation.test.vitest.mjs— no orphaned API-cache entry afterapi.remove()by path or moduleID, or after a reload following a remove; partial removals keep the entry.tests/vitests/suites/core/core-reload-full.test.vitest.mjs— strengthened to assert cache + ownership state (not just theapitree) after a reload's replayed removes.
Coverage is at 100% across statements, branches, functions, and lines.