/** * Shared boot glue for the app bins (`dsh`, `dsh-acp-demo`): load the gitignored * `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the * optional user patch layers from the Harness home (`~/.dsh`), expose its path resolver to * config expressions, and drive the Cordis Loader against a leaf `cordis.yml` until the tree settles. * @module @deepseek-ai/dsh-app-boot */ import { pathToFileURL } from 'node:url' import { readFileSync } from 'node:fs' import { parseEnv } from 'node:util' import { basename, dirname, isAbsolute, resolve } from 'node:path' import * as yaml from 'js-yaml' import { Context, type FiberState } from '@deepseek-ai/cordis' import Loader, { type Entry, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import Group from '@deepseek-ai/cordis-plugin-group' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' import { createEnvironmentSnapshot, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import type {} from '@deepseek-ai/cordis-plugin-hmr' // Side-effect type import: resolves `ctx.get('systemPrompt')` to the service. import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/cordis' { interface Context { /** Harness-home path resolver available to Loader `!!js` config expressions. */ dshHomePath?: typeof dshHomePath } } export { composeEntries, DEFAULT_PROFILE_BUNDLES, healProfilesModuleFallback, initProfile, loadProfile, PROFILE_PATCH_FILENAME, PROFILE_TEMPLATES, PROFILES_DIR, readProfileManifest, resolveBundleDir, resolveProfileDir, writeProfileManifest, type DshBundleManifest, type DshManifestSection, type DshProfileManifest, type Profile, type ProfileLayer, type ProfileManifest, } from './profile.ts' /** * Resolve the config to boot. Replay swaps a `cordis.yml` basename for * `cordis.snapshot.yml` in the same directory; every other mode keeps the path. * @param configPath - the requested config path (absolute, or relative to `cwd`). * @param snapshotMode - the bin's `$DSH_SNAPSHOT` value; only `'replay'` swaps the * basename. * @param cwd - the base a relative `configPath` resolves against. * @returns the absolute path of the config to boot. */ export function resolveConfigPath( configPath: string, snapshotMode: string | undefined, cwd: string = process.cwd(), ): string { const absolute = resolve(cwd, configPath) if (snapshotMode !== 'replay') return absolute const dir = dirname(absolute) const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml') return resolve(dir, replayName) } /** * Load the optional gitignored `.env` from `dir`. Missing files fall back to the * ambient environment; other read failures are reported through `warn`. * @param binName - the diagnostic prefix on the warn line. * @param dir - the directory whose `.env` to load. * @param warn - sink for the one-line misconfiguration diagnostic. */ export function loadEnv( binName: string, dir: string = process.cwd(), warn: (line: string) => void = line => void process.stderr.write(line), ): void { try { process.loadEnvFile(resolve(dir, '.env')) } catch (error) { if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') { warn(`${binName}: failed to load .env: ${String(error)}\n`) } // ENOENT (no .env) is fine — rely on the ambient environment. } } /** Exact names no discovered file may set. */ const BOOTSTRAP_NAMES = new Set([ // Process launch and module resolution. 'PATH', 'HOME', 'USERPROFILE', 'SHELL', 'NODE_OPTIONS', 'NODE_PATH', 'NODE_EXTRA_CA_CERTS', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'LD_AUDIT', // Interpreter startup hooks. 'BASH_ENV', 'ENV', 'SHELLOPTS', 'BASHOPTS', 'PERL5OPT', 'PERL5LIB', 'PYTHONSTARTUP', 'PYTHONPATH', 'RUBYOPT', 'RUBYLIB', 'JAVA_TOOL_OPTIONS', '_JAVA_OPTIONS', 'JDK_JAVA_OPTIONS', 'PYTHONHOME', // Version-control command hooks and config redirects. 'GIT_SSH', 'GIT_SSH_COMMAND', 'GIT_EXTERNAL_DIFF', 'GIT_PAGER', 'GIT_EDITOR', 'GIT_ASKPASS', 'SSH_ASKPASS', 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_COUNT', 'EDITOR', 'VISUAL', 'PAGER', // Network reach and trust. 'DEEPSEEK_BASE_URL', 'DEEPSEEK_SEARCH_BASE_URL', 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE', 'NODE_TLS_REJECT_UNAUTHORIZED', ]) /** Name prefixes no discovered file may set. */ const BOOTSTRAP_PREFIXES = ['DSH_', 'XDG_', 'DYLD_', 'BASH_FUNC_'] /** * Whether a variable may come only from the inherited process environment * because it changes process, runtime, VCS, or network bootstrap. * @param name - the variable name. * @returns true when only the inherited environment may supply it. */ function isBootstrapOnly(name: string): boolean { const upper = name.toUpperCase() return BOOTSTRAP_NAMES.has(upper) || BOOTSTRAP_PREFIXES.some(prefix => upper.startsWith(prefix)) } /** * Parse one directory's `.env` without applying it, rejecting bootstrap-only * names before any value is materialized. * @param binName - the diagnostic prefix on the thrown error. * @param dir - the directory whose `.env` to read. * @param warn - sink for the one-line unreadable-file diagnostic. * @returns the parsed entries, or `undefined` when the file is absent or unreadable. * @throws when the file declares a name {@link isBootstrapOnly} rejects. */ function readEnvLayer( binName: string, dir: string, warn: (line: string) => void, ): { path: string; values: Record } | undefined { const path = resolve(dir, '.env') let content: string try { content = readFileSync(path, 'utf8') } catch (error) { if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') { warn(`${binName}: failed to load .env: ${String(error)}\n`) } // ENOENT (no .env) is fine — rely on the ambient environment. return undefined } // Parse once so validation and materialization use exactly the same entries. const values = parseEnv(content) as Record for (const name of Object.keys(values)) { if (!isBootstrapOnly(name)) continue throw new Error( `${binName}: ${path} sets "${name}", which only the launching environment may set` + ' (it decides how this process starts, where its code and instructions load from, or how it' + ` reaches the network); export ${name} instead of putting it in a .env file`, ) } return { path, values } } /** * Load the product CLI's inherited > invoking-directory `.env` > Harness-home * `.env` snapshot. The Harness home resolves before either file; both files * are checked before either is applied, and accepted values are materialized * without replacing inherited ones. The snapshot preserves which layer supplied each value. * @param binName - the diagnostic prefix on the diagnostics. * @param cwd - the invoking directory whose `.env` is the project layer. * @param warn - sink for the one-line misconfiguration diagnostics. * @returns this run's frozen environment snapshot. * @throws when either file declares a bootstrap-only variable. */ export function loadLayeredEnv( binName: string, cwd: string = process.cwd(), warn: (line: string) => void = line => void process.stderr.write(line), ): EnvironmentSnapshot { const home = resolveDshHome() const inherited = { ...process.env } as Record // Parse both layers first: a rejection must not leave one file applied. const project = readEnvLayer(binName, cwd, warn) const user = home === resolve(cwd) ? undefined : readEnvLayer(binName, home, warn) // Apply the checked values without replacing a higher-ranked name. for (const layer of [project, user]) { if (layer === undefined) continue for (const [name, value] of Object.entries(layer.values)) { if (process.env[name] === undefined) process.env[name] = value } } return createEnvironmentSnapshot([ { source: 'process', values: inherited }, ...project === undefined ? [] : [{ source: 'project-env' as const, path: project.path, values: project.values }], ...user === undefined ? [] : [{ source: 'user-env' as const, path: user.path, values: user.values }], ]) } const bootstrapIncludes = new WeakMap() // The include's YAML dialect (`!!js` scalars become expression nodes the // Loader interpolates against each entry's injection-ready context), imported // from the include itself so patch parsing and config dumping can never drift // from what the include mounts. User patch layers share it so they may // reference `process.env`. const userPatchesSchema = entryListSchema /** Options for live user patch-layer reconciliation. */ export interface UserPatchWatchOptions { /** Diagnostic prefix used by {@link loadOptionalPatches}. */ binName: string /** Absolute path of the watched patch file (a profile's `cordis.patch.yml`). */ filename: string /** * Compose the full patch list for a fresh user-layer generation — * the same composition the app booted with, so a reload can interleave the * new user patches between app-owned layers (bundle layers below, * overlays above). Identity when omitted: the user layer * is the whole patch list. */ compose?: (userPatches: PatchOptions[]) => PatchOptions[] } /** * Watch the user patch layer through Cordis HMR and transactionally reapply it to the boot include. * @param ctx - settled app context containing the root Include and an active HMR service. * @param options - diagnostic, file, and patch-composition inputs. * @returns an asynchronous disposer after the exact-path watcher is ready. * @throws when HMR or the root Include is absent, watcher setup fails, or initial path resolution fails. */ export async function watchUserPatches( ctx: Context, options: UserPatchWatchOptions, ): Promise<() => Promise> { const { binName, filename, compose = (patches: PatchOptions[]) => patches } = options const hmr = ctx.get('hmr') if (hmr === undefined) throw new Error(`${binName}: user patch-layer watching requires the Cordis HMR service`) const entry = bootstrapIncludes.get(ctx) if (entry === undefined) throw new Error(`${binName}: user patch-layer watching requires the root Include entry`) const register = hmr.registerConfig(filename, async () => { // Re-read the include's non-patch options per refresh: a writer that // updates the root Include's other options between refreshes (none exists // today) must not have them silently reverted by a user-layer reload. const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config const userPatches = loadOptionalPatches(binName, filename) ?? [] const patches = compose(userPatches) await entry.update({ config: { ...includeConfig, patches, }, }) }) try { return await register } catch (error) { // A surface can dispose the whole tree while the watcher is still opening; // the HMR effect registration then fails with INACTIVE_EFFECT. That is the // app exiting exactly as asked, not a watch failure, so return a no-op // disposer instead of crashing. if ((error as { code?: string } | null)?.code === 'INACTIVE_EFFECT') return async () => {} throw error } } /** * Load an optional patch-list file: a top-level YAML array of loader patch * entries (`@deepseek-ai/cordis-plugin-include`'s `PatchOptions`): id-targeted config * overrides and `insert` lists, with `!!js` expressions allowed. A missing * file means "no layer"; an unreadable, unparsable, or non-array file throws — * a present patch file that cannot apply is a misconfiguration and must fail * loud at boot, never be silently skipped. * @param binName - the diagnostic prefix on the thrown error. * @param file - absolute path of the patch file. * @returns the parsed patches, or `undefined` when the file does not exist. */ export function loadOptionalPatches(binName: string, file: string): PatchOptions[] | undefined { let content: string try { content = readFileSync(file, 'utf8') } catch (error) { if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined throw new Error(`${binName}: failed to read patches ${file}: ${String(error)}`) } return parsePatchList(binName, file, content, 'patches') } /** * Load a required overlay patch list: a bundle's `cordis.patch.yml` or a * `--patch ` overlay. Same file format as {@link loadOptionalPatches}, * but a missing file throws, because the caller named this file — its absence * is a misconfiguration, not "no overlay". * @param binName - the diagnostic prefix on the thrown error. * @param file - absolute path of the overlay file. * @returns the parsed patch list. */ export function loadOverlayPatches(binName: string, file: string): PatchOptions[] { let content: string try { content = readFileSync(file, 'utf8') } catch (error) { throw new Error(`${binName}: failed to read overlay ${file}: ${String(error)}`) } return parsePatchList(binName, file, content, 'overlay') } /** * Parse one loader patch list: a top-level YAML array of * `@deepseek-ai/cordis-plugin-include` `PatchOptions` (id-targeted config overrides and * `insert` lists, `!!js` expressions allowed). Every invalid field or value throws, * because a patch file that cannot be applied at all is a misconfiguration; a * single patch whose target row is absent stays a per-entry Loader warning, so * one overlay shared across surfaces does not have to match every tree. * @param binName - the diagnostic prefix on the thrown error. * @param file - the source path, quoted in errors. * @param content - the file's text. * @param label - what to call this list in errors (`patches`, `overlay`). * @returns the parsed patch list. */ function parsePatchList( binName: string, file: string, content: string, label: string, ): PatchOptions[] { let parsed: unknown try { parsed = yaml.load(content, { schema: userPatchesSchema }) } catch (error) { throw new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`) } if (!Array.isArray(parsed)) { throw new Error(`${binName}: ${label} ${file} must be a top-level YAML array of loader patch entries`) } parsed.forEach((entry, index) => { if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { throw new Error(`${binName}: ${label} entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`) } }) return parsed as PatchOptions[] } /** One overlay patch list with the source label printed in dump comments. */ export interface ConfigDumpLayer { /** Source name shown in dump comments (a file basename or path). */ label: string /** The layer's patches, from {@link loadOverlayPatches} / {@link loadOptionalPatches}. */ patches: PatchOptions[] } /** * Compose the effective entry list exactly as `boot()` would mount it: parse * the base config file with the include's entry-list dialect, apply every * layer's patches as ONE flattened list through the include's own patch * algorithm (`applyEntryPatches`) — the same single call `boot()` makes, so * even patch-visibility corner cases (a later layer targeting a group child a * plain config replacement introduced, which the single-pass id index never * sees) compose identically — then render the result as YAML in the same * dialect (`!!js` expressions print verbatim, unevaluated). * * Every run of rows from the same file and patch layers is preceded by a `# ==` comment * naming the file that contributed the rows and any layers that patched them, * so the output stays a loadable YAML document while showing which section * comes from which file. The file and patch labels are derived from single-call prefix * snapshots (base + layers 1..k), diffed positionally: the patch algorithm * only rewrites rows in place or appends, so a top-level index identifies one * row across snapshots, and a layer whose addition changes the row (config * replacement, disable, group insert) is listed as having patched it. * * A patch that matches no row is reported through `warn` with its layer * label, mirroring the Loader's boot-time warning. Earlier layers' patches * see an identical preceding state in every snapshot that includes them, so * each snapshot's warning list extends the previous one and the new tail * belongs to the added layer. * @param binName - the diagnostic prefix on read/parse errors. * @param absoluteConfigPath - the base config file `boot()` would include. * @param layers - overlay layers in application order (later wins). * @param warn - sink for skipped-patch diagnostics; defaults to stderr. * @returns the composed entry list rendered as a YAML document with * source comment separators. */ export function renderConfigDump( binName: string, absoluteConfigPath: string, layers: ConfigDumpLayer[], warn: (line: string) => void = line => void process.stderr.write(`${line}\n`), ): string { let content: string try { content = readFileSync(absoluteConfigPath, 'utf8') } catch (error) { throw new Error(`${binName}: failed to read config ${absoluteConfigPath}: ${String(error)}`) } let parsed: unknown try { parsed = yaml.load(content, { schema: entryListSchema }) } catch (error) { throw new Error(`${binName}: failed to parse config ${absoluteConfigPath}: ${String(error)}`) } if (!Array.isArray(parsed)) { throw new Error(`${binName}: config ${absoluteConfigPath} must be a top-level YAML array of entries`) } const baseLabel = basename(absoluteConfigPath) // YAML parsing yields untyped rows; the include validates each entry // at mount, and the dump prints whatever the file holds, so `EntryOptions` // here is structural trust in the same file `boot()` would include. const base = parsed as Parameters[0] // snapshot_k = ONE application of layers 1..k flattened, using the exact // arguments boot passes for that prefix. snapshot_N is the mounted composition. // The patches are cloned per call: applyEntryPatches detaches the entry // list but pushes `insert` rows by reference from the patch list, so // sharing patch objects across snapshot calls would leak a later // snapshot's mutations into an earlier one's result. const snapshot = (count: number, warnings: string[]): ReturnType => { const flattened = structuredClone(layers.slice(0, count).flatMap(layer => layer.patches)) return applyEntryPatches(base, flattened, (message: string, ...args: unknown[]) => { // The include logs through cordis's printf-style logger (`%C` = code); a // dump has no logger, so substitute inline for a plain line. let index = 0 warnings.push(message.replace(/%C/g, () => JSON.stringify(args[index++]))) }) } let previous = base let previousWarnings: string[] = [] const provenance: { origin: string; patchedBy: string[] }[] = base.map(() => ({ origin: baseLabel, patchedBy: [] })) let composed = base for (let count = 1; count <= layers.length; count += 1) { const layer = layers[count - 1] /* v8 ignore next -- count iterates 1..length, so the slot exists */ if (layer === undefined) continue const warnings: string[] = [] composed = snapshot(count, warnings) for (const line of warnings.slice(previousWarnings.length)) { warn(`${binName}: [${layer.label}] ${line}`) } const before = previous.map(entry => JSON.stringify(entry)) for (let index = 0; index < composed.length; index += 1) { if (index >= before.length) provenance.push({ origin: layer.label, patchedBy: [] }) else if (JSON.stringify(composed[index]) !== before[index]) provenance[index]?.patchedBy.push(layer.label) } previous = composed previousWarnings = warnings } return groupedDump(composed, provenance) } /** Render the composed rows grouped under one source-and-patches comment per contiguous run. */ function groupedDump( composed: readonly unknown[], provenance: readonly { origin: string; patchedBy: string[] }[], ): string { const lines: string[] = [] let currentLabel: string | undefined let group: unknown[] = [] const flush = (): void => { if (currentLabel === undefined || group.length === 0) return lines.push(`# == ${currentLabel}`) lines.push(yaml.dump(group, { schema: entryListSchema, noRefs: true }).trimEnd()) group = [] } for (let index = 0; index < composed.length; index += 1) { const record = provenance[index] /* v8 ignore next -- this array is index-aligned with composed by construction */ if (record === undefined) continue const label = record.patchedBy.length === 0 ? record.origin : `${record.origin}, patched by ${record.patchedBy.join(', ')}` if (label !== currentLabel) { flush() currentLabel = label } group.push(composed[index]) } flush() return lines.join('\n') + '\n' } /** * Mount and remember the exact root Include entry used by app boot and user patch-layer HMR. * @param ctx - context carrying an initialized Loader service. * @param absoluteConfigPath - absolute YAML or JSON configuration path. * @param patches - initial app and user patches, applied in order. * @param bareModuleBaseUrl - optional installed-host base for bare package * names; relative names continue to resolve beside the configuration file. * @returns the created root Include entry, or `undefined` when a surface * disposed the whole tree (taking the Loader service with it) while the * transactional create was still settling entry lifecycle. */ export async function mountRootInclude( ctx: Context, absoluteConfigPath: string, patches: readonly PatchOptions[] = [], bareModuleBaseUrl?: string, ): Promise { ctx.loader.builtins.include = bareModuleBaseUrl === undefined ? Include : class HostResolvedRootInclude extends Include { override import(name: string, getOuterStack?: () => string[]): unknown { const specifier = isAbsolute(name) ? pathToFileURL(name).href : name if (name.startsWith('.') || name.startsWith('cordis:')) return super.import(specifier, getOuterStack) const internal = this.ctx.loader.internal /* v8 ignore next -- Node supplies the internal loader; this preserves the original diagnostic for hypothetical embedders without it. */ if (internal === undefined) return super.import(specifier, getOuterStack) return internal.import(specifier, bareModuleBaseUrl, {}) } } // `cordis:group` alongside it: a group row is how a composition gives one // `isolate` realm to a provider and its consumers together, and an agent // preset living outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` // by name. Both builtins load through the ambient module pipeline, so neither // depends on the included tree's own specifier resolution. ctx.loader.builtins.group = Group // Pinned id: the bootstrap include is app glue, not a config row, and its // id appears in Loader failure chains — a random id would make startup // diagnostics unstable across runs (and snapshot fixtures). const includeConfig: Include.Config = { path: pathToFileURL(absoluteConfigPath).href, ...patches.length > 0 ? { patches: [...patches] } : {}, } const rootInclude: EntryOptions = { id: 'include', name: 'cordis:include', config: includeConfig, } const includeId = await ctx.loader.create(rootInclude) const loader = ctx.get('loader') if (loader === undefined) return undefined const entry = loader.resolve(includeId) bootstrapIncludes.set(ctx, entry) return entry } /** * The slice of `process` {@link installFailLoud} needs — injectable so tests * exercise the handler without registering on (or exiting) the real process. */ export interface FailLoudProcess { on(event: 'unhandledRejection', handler: (err: unknown) => void): unknown off(event: 'unhandledRejection', handler: (err: unknown) => void): unknown stderr: { write(chunk: string): unknown } /** * Terminate the process. Callers treat this as the end of the run, as * `process.exit` is; a fake that returns lets the caller continue, which only * a test observes. */ exit(code: number): void } // Loader rc.5 derives and drops a rejected promise after a fiber fails. Keep // exact reasons already folded into the boot diagnostic visible through the // next process rejection checkpoint so the process guard can coalesce them. const assembledActivationRejections = new Map() function retainAssembledRejection(reason: unknown): void { assembledActivationRejections.set(reason, (assembledActivationRejections.get(reason) ?? 0) + 1) } function releaseAssembledRejection(reason: unknown): void { const count = assembledActivationRejections.get(reason) if (count === undefined || count === 1) { assembledActivationRejections.delete(reason) } else { assembledActivationRejections.set(reason, count - 1) } } async function observeLoaderRejectionCheckpoint(reasons: readonly unknown[]): Promise { for (const reason of reasons) retainAssembledRejection(reason) try { await new Promise(resolve => setImmediate(resolve)) } finally { for (const reason of reasons) releaseAssembledRejection(reason) } } /** * How long {@link installFailLoud} waits for its `release` hook before exiting * anyway. A wedged disposer must delay the fatal exit, never cancel it. */ export const FAIL_LOUD_RELEASE_TIMEOUT_MS = 2_000 /** * Install before boot to turn a late unhandled plugin-init rejection into one * labelled stderr diagnostic and `exit(1)`. A rejection already included by * {@link assertEntriesActivated} is ignored during its process checkpoint; * every other rejection remains fatal. Stdout remains untouched for ACP; the * returned function removes the handler. * * The Loader mounts entries concurrently, so a surface that owns the terminal * can already hold it when a sibling entry rejects. Exiting straight from the * handler would strand raw mode, bracketed paste, and the keyboard protocol on * the user's shell, and leave an in-flight terminal query's reply to land as * literal text at the next prompt. `release` is the terminal owner's chance to * hand it back; it is awaited under {@link FAIL_LOUD_RELEASE_TIMEOUT_MS}, whose * timer stays referenced so a never-settling disposer cannot let Node reach an * empty event loop and exit 0 instead of failing. * * The diagnostic is written before the release so a hanging or failing disposer * cannot swallow the reason. The handler stays installed while the release runs * — removing it would let a second concurrent rejection become uncaught and kill * the process mid-teardown, stranding exactly the terminal state this restores — * so a latch keeps the first rejection the reported one and lets later * rejections (including the release's own) fall through to the pending exit. * @param binName - the diagnostic prefix on the fatal-failure line. * @param proc - the process slice to register on; tests inject a fake. * @param release - optional teardown awaited before exit, used by a * terminal-owning surface to restore the terminal. Its own failure is * swallowed because the pending fatal exit already owns the outcome. * @returns the uninstaller that removes the rejection handler. */ export function installFailLoud( binName: string, proc: FailLoudProcess = process, release?: () => Promise | void, ): () => void { let exiting = false const handler = (err: unknown): void => { if (assembledActivationRejections.has(err)) return // A release in flight already owns the exit. Swallow later rejections // (teardown's own included) rather than reporting a second failure over the // real one or letting Node kill the process before the terminal is back. if (exiting) return exiting = true proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`) if (release === undefined) { proc.exit(1) return } void (async () => { // Definitely assigned: the timeout promise's executor runs synchronously // while the race is being constructed, before the first await. let timer!: ReturnType try { await Promise.race([ (async () => release())(), new Promise((resolve) => { timer = setTimeout(resolve, FAIL_LOUD_RELEASE_TIMEOUT_MS) }), ]) } catch { // The terminal release failed; the fatal exit below is the outcome that // matters, and no reporter runs after it. } clearTimeout(timer) proc.exit(1) })() } const uninstall = (): void => void proc.off('unhandledRejection', handler) proc.on('unhandledRejection', handler) return uninstall } /** * After the tree settles, reject entries with no fiber and name every plugin * whose module failed to resolve. Disabled entries are the only valid * fiber-less state. * @param ctx - the settled context whose loader entries to audit. * @param binName - the diagnostic prefix on the thrown error. */ export function assertEntriesLoaded(ctx: Context, binName: string): void { const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled) if (failed.length > 0) { const names = failed.map(entry => entry.options.name).join(', ') throw new Error(`${binName}: plugin(s) failed to load: ${names}; Cordis startup failed because these plugin(s) could not be resolved (see the error(s) logged above)`) } } /** * Value mirrors used because Cordis's const enum has no runtime object to import. * Keep aligned with `packages/self-modification/tool-cordis/src/fiber-state.ts` and * `packages/client/web/src/loader-status.ts`. */ const FIBER_PENDING = 0 as FiberState.PENDING const FIBER_ACTIVE = 2 as FiberState.ACTIVE const FIBER_FAILED = 3 as FiberState.FAILED /** Render a thrown plugin value without discarding an Error's original stack. */ function formatActivationError(error: unknown): string { return error instanceof Error ? error.stack ?? error.message : String(error) } /** * Reject a settled Loader tree when an enabled entry failed or remains inactive. * Plugin failures include the original thrown stack; pending entries name their * unresolved services because no plugin error exists for that state. Active * entries require no further wait; only failed fibers are awaited to recover * their private rejection reason. * @param ctx - the settled context whose Loader entries to audit. * @param binName - the diagnostic prefix on the thrown error. * @returns nothing when every enabled entry is active. * @throws after one process rejection checkpoint when an entry failed to * import, rejected during activation, or did not become active. */ export async function assertEntriesActivated(ctx: Context, binName: string): Promise { assertEntriesLoaded(ctx, binName) const failures: string[] = [] const rejectionReasons: unknown[] = [] for (const entry of ctx.loader.entries()) { const fiber = entry.fiber if (fiber === undefined || entry.disabled) continue const state = fiber.state if (state === FIBER_ACTIVE) continue if (state === FIBER_FAILED) { try { await fiber.await() } catch (error) { rejectionReasons.push(error) failures.push(`${entry.options.name}: ${formatActivationError(error)}`) } continue } if (state === FIBER_PENDING) { const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined) const subject = missing.length === 1 ? 'service' : 'services' failures.push(`${entry.options.name}: pending (waiting for ${subject}: ${missing.join(', ') || 'unknown'})`) } else { failures.push(`${entry.options.name}: fiber state ${String(state)}`) } } if (failures.length > 0) { if (rejectionReasons.length > 0) { await observeLoaderRejectionCheckpoint(rejectionReasons) } const noun = failures.length === 1 ? 'entry' : 'entries' throw new Error(`${binName}: ${String(failures.length)} ${noun} did not activate\n${failures.join('\n')}`) } } /** * Boot the Loader against `absoluteConfigPath` and return only after the whole * tree settles. Relative entry names resolve against the config directory; * bare package names resolve there by default or against an explicit * `bareModuleBaseUrl` for closed packaged runtimes. The bootstrap include * is statically imported and mounted as the `cordis:include` builtin, loading * through the ambient module pipeline (vite/tsx/plain ESM). The package build * embeds Include while leaving Loader external, so the built include tree and * host share one Loader peer. Loader * settlement rejects startup failures, which `boot` wraps after disposing the * partial context; a missing fiber or never-activating entry is rejected by * the final audit, {@link assertEntriesActivated}, which rethrows a plugin's * init rejection with its original stack; later unhandled rejections remain * covered by {@link installFailLoud}. Built bins need the Loader's native * helper for bare plugin specifiers; relative specifiers do not. * @param binName - the diagnostic prefix for load-failure errors. * @param absoluteConfigPath - the config to include; must already be absolute * (see {@link resolveConfigPath}). * @param patches - optional overlay patches applied over the included tree * (see {@link loadOptionalPatches}); an empty list mounts none. * @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts. * @param bareModuleBaseUrl - optional installed-host base for bare package * names; use it when the host, rather than the configuration project, owns the * complete plugin set. * @returns the root context once every entry has started, or as soon as a * surface disposed the tree while startup was still in flight. * @throws a labelled error after disposing the partial context — `host * preparation failed` when `prepare` threw before any config-tree entry * mounted, `plugin tree failed to load` afterwards. */ export async function boot( binName: string, absoluteConfigPath: string, patches?: PatchOptions[], prepare?: (ctx: Context) => Promise | void, bareModuleBaseUrl?: string, ): Promise { const ctx = new Context() // Two failure labels: `prepare` runs before any config-tree entry mounts, // so its failure is host setup, not the plugin tree. let stage = 'host preparation failed' try { ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' ctx.provide('dshHomePath', dshHomePath) await ctx.plugin(Loader) await prepare?.(ctx) stage = 'plugin tree failed to load' await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl) // A surface can finish and dispose the whole tree while startup is still // in flight, before the last entry settles. The Loader service goes with // it, and the activation audit describes a live tree — reading `ctx.loader` // past this point would throw a TypeError over an app that exited exactly // as asked. Transactional group updates settle // lifecycle inside the mount, so the teardown can land before it returns; // re-check after every await. await ctx.get('loader')?.await() if (ctx.get('loader') === undefined) return ctx await assertEntriesActivated(ctx, binName) return ctx } catch (cause) { // Root-fiber disposal contains cleanup failures per observer (Cordis // fiber.ts hardening) and a repeated call returns the settled single-shot // result, so this await cannot reject and replace `cause`. await ctx.fiber.dispose() const detail = cause instanceof Error ? cause.message : String(cause) // The transactional Loader wraps a failing entry apply in one message per // tree layer; every layer's message is folded into `detail` above, and the // deepest cause is the plugin's own thrown error, whose stack names the // real failure site — append it so the startup diagnostic preserves the // original activation error instead of only the wrap chain. let deepest: unknown = cause while (deepest instanceof Error && deepest.cause !== undefined) deepest = deepest.cause const stack = deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : '' throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause }) } } /** Prompt-section name for the harness-source location line an app bin adds after boot. */ export const HARNESS_SOURCE_SECTION = 'harness:source' /** * Add a global prompt section naming the on-disk harness source checkout while * explicitly distinguishing it from the task workspace and current working * directory. The self-referential `dsh-tool-cordis` toolset reads and edits this * checkout. Call once on the settled boot context ({@link boot}); the section * orders just after the harness identity opener (`-100`) and before the deployment * persona (`0`). A booted tree with no `systemPrompt` service has no prompt to * augment, so this is then a no-op that returns `undefined`. The section is * registered against the `systemPrompt` service's fiber, so a dev HMR reload of * that plugin drops it until the next boot. * @param ctx - the settled boot context whose global system prompt to augment. * @param sourceRoot - the absolute path to the harness checkout root. * @returns the section disposer, or `undefined` when no `systemPrompt` service is mounted. */ export function addHarnessSourceSection(ctx: Context, sourceRoot: string): (() => void) | undefined { const systemPrompt = ctx.get('systemPrompt') if (systemPrompt === undefined) return undefined return systemPrompt.section({ name: HARNESS_SOURCE_SECTION, order: -99, text: `The DeepSeek Harness implementation checkout is at ${sourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`, }) }