2026-07-04 16:43:06 +08:00
/**
2026-07-25 14:15:25 +08:00
* Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored
2026-07-22 10:55:16 +08:00
* `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the
* optional personal overlay patches from the Harness home (`~/.dsh`), and drive the cordis Loader
* against a leaf `cordis.yml` until the whole tree has settled.
2026-07-04 16:43:06 +08:00
* @module @deepseek-ai/dsh-app-boot
*/
import { pathToFileURL } from 'node:url'
2026-07-22 10:55:16 +08:00
import { readFileSync } from 'node:fs'
import { basename , dirname , join , resolve } from 'node:path'
import * as yaml from 'js-yaml'
2026-07-04 16:43:06 +08:00
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
2026-07-22 10:55:16 +08:00
import Include , { type PatchOptions } from '@cordisjs/plugin-include'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
// Side-effect type import: resolves `ctx.get('systemPrompt')` to the service.
import type { } from '@deepseek-ai/dsh-system-prompt'
2026-07-04 16:43:06 +08:00
/**
2026-07-13 23:27:00 +08:00
* 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.
2026-07-06 22:09:30 +08:00
* @param configPath - the requested config path (absolute, or relative to `cwd`).
2026-07-12 03:36:43 +08:00
* @param snapshotMode - the bin's `$DSH_SNAPSHOT` value; only `'replay'` swaps the
* basename.
2026-07-06 22:09:30 +08:00
* @param cwd - the base a relative `configPath` resolves against.
* @returns the absolute path of the config to boot.
2026-07-04 16:43:06 +08:00
*/
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 )
}
/**
2026-07-13 16:24:32 +08:00
* Load the optional gitignored `.env` from `dir`. Missing files fall back to the
* ambient environment; other read failures are reported through `warn`.
2026-07-06 22:09:30 +08:00
* @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.
2026-07-04 16:43:06 +08:00
*/
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.
}
}
2026-07-22 10:55:16 +08:00
/** File inside the Harness home holding the personal loader overlay patches. */
export const PERSONAL_CONFIG_FILENAME = 'config.yaml'
// The include's YAML dialect: `!!js` scalars become expression nodes the
// Loader interpolates against each entry's context at mount time. Personal
// patches are parsed with the same schema so they may reference `process.env`.
// Load-only: this schema never dumps, so no `predicate`/`represent`.
const jsExprType = new yaml . Type ( 'tag:yaml.org,2002:js' , {
kind : 'scalar' ,
resolve : data = > typeof data === 'string' ,
construct : data = > ( { __jsExpr : String ( data ) } ) ,
} )
const personalPatchesSchema = yaml . JSON_SCHEMA . extend ( jsExprType )
/**
* Load the optional personal overlay patches (`config.yaml` under the Harness
* home). The file is a top-level YAML array of loader patch entries
* (`@cordisjs/plugin-include`'s `PatchOptions`): id-targeted config overrides
* and `insert` lists, with `!!js` expressions allowed. A missing file means
* "no personal overlay"; an unreadable, unparsable, or non-array file throws —
* a present personal config 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 dir - the Harness home; defaults to {@link resolveDshHome} (`$DSH_HOME` or `~/.dsh`).
* @returns the parsed patches, or `undefined` when the file does not exist.
*/
export function loadPersonalPatches (
binName : string , dir : string = resolveDshHome ( ) ,
) : PatchOptions [ ] | undefined {
const file = join ( dir , PERSONAL_CONFIG_FILENAME )
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 personal patches ${ file } : ${ String ( error ) } ` )
}
let parsed : unknown
try {
parsed = yaml . load ( content , { schema : personalPatchesSchema } )
} catch ( error ) {
throw new Error ( ` ${ binName } : failed to parse personal patches ${ file } : ${ String ( error ) } ` )
}
if ( ! Array . isArray ( parsed ) ) {
throw new Error ( ` ${ binName } : personal patches ${ file } must be a top-level YAML array of loader patch entries ` )
}
// A present personal config that cannot apply is a misconfiguration and must
// fail loud here — the include only warns per entry at mount.
parsed . forEach ( ( entry , index ) = > {
if ( typeof entry !== 'object' || entry === null || Array . isArray ( entry ) ) {
throw new Error ( ` ${ binName } : personal patches entry ${ index + 1 } in ${ file } must be a mapping (a loader patch entry) ` )
}
} )
return parsed as PatchOptions [ ]
}
2026-07-04 16:43:06 +08:00
/**
* 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 }
exit ( code : number ) : void
}
/**
2026-07-13 23:27:00 +08:00
* Install before boot to turn a late unhandled plugin-init rejection into one
* labelled stderr diagnostic and `exit(1)`. Stdout remains untouched for ACP;
* the returned function removes the handler.
2026-07-06 22:09:30 +08:00
* @param binName - the diagnostic prefix on the fatal-failure line.
* @param proc - the process slice to register on; tests inject a fake.
* @returns the uninstaller that removes the rejection handler.
2026-07-04 16:43:06 +08:00
*/
export function installFailLoud ( binName : string , proc : FailLoudProcess = process ) : ( ) = > void {
const handler = ( err : unknown ) : void = > {
proc . stderr . write ( ` ${ binName } : fatal load failure: ${ err instanceof Error ? err . stack ? ? err.message : String ( err ) } \ n ` )
proc . exit ( 1 )
}
proc . on ( 'unhandledRejection' , handler )
return ( ) = > void proc . off ( 'unhandledRejection' , handler )
}
/**
2026-07-28 23:05:20 +08:00
* After the tree settles, reject entries with no fiber and name every plugin
* whose module failed to resolve. Disabled entries are the only valid
2026-07-13 23:27:00 +08:00
* fiber-less state.
2026-07-06 22:09:30 +08:00
* @param ctx - the settled context whose loader entries to audit.
* @param binName - the diagnostic prefix on the thrown error.
2026-07-04 16:43:06 +08:00
*/
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 ( ', ' )
2026-07-28 23:05:20 +08:00
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) ` )
2026-07-04 16:43:06 +08:00
}
}
2026-07-25 12:02:28 +08:00
/**
* Context key a bin sets through {@link boot}'s `prepare` hook to hand a resume
* session id to the booted config: `ctx.provide(RESUME_SESSION_ID_KEY, id)`
* makes `id` readable as the bare identifier `resumeSessionId` in a config
* `!!js` expression. The value is the bin's already-parsed id (or `undefined`),
* so resuming a session needs no environment variable. A bin that never
* provides it leaves the identifier undeclared, so configs read it defensively
* (`typeof resumeSessionId === 'string' ? resumeSessionId : undefined`).
*/
export const RESUME_SESSION_ID_KEY = 'resumeSessionId'
2026-07-04 16:43:06 +08:00
/**
2026-07-13 23:27:00 +08:00
* Boot the Loader against `absoluteConfigPath` and return only after the whole
2026-07-15 11:51:29 +08:00
* tree settles. Entry names load through the Loader's internal module loader
* against `baseUrl` (the config directory), which may live outside
* `node_modules` reach and, unbuilt, cannot load vendored source; the
* bootstrap include is therefore statically imported and mounted as the
* `cordis:include` builtin, loading through the ambient module pipeline
* (vite/tsx/plain ESM) while the included tree's own specifiers stay
* config-relative. A missing fiber rejects here; a later init rejection is
2026-07-23 20:35:09 +08:00
* handled by {@link installFailLoud}. Built bins need the Loader's native
* helper for bare plugin specifiers; relative specifiers do not.
2026-07-06 22:09:30 +08:00
* @param binName - the diagnostic prefix for load-failure errors.
* @param absoluteConfigPath - the config to include; must already be absolute
* (see {@link resolveConfigPath}).
2026-07-22 10:55:16 +08:00
* @param patches - optional overlay patches applied over the included tree
* (see {@link loadPersonalPatches}); an empty list mounts none.
2026-07-24 12:31:26 +08:00
* @param prepare - optional host setup run against the root context before any Loader entry mounts.
2026-07-06 22:09:30 +08:00
* @returns the root context once every entry has started.
2026-07-04 16:43:06 +08:00
*/
2026-07-22 10:55:16 +08:00
export async function boot (
2026-07-24 12:31:26 +08:00
binName : string ,
absoluteConfigPath : string ,
patches? : PatchOptions [ ] ,
prepare ? : ( ctx : Context ) = > Promise < void > | void ,
2026-07-22 10:55:16 +08:00
) : Promise < Context > {
2026-07-04 16:43:06 +08:00
const ctx = new Context ( )
2026-07-24 12:31:26 +08:00
await prepare ? . ( ctx )
2026-07-04 16:43:06 +08:00
ctx . baseUrl = pathToFileURL ( dirname ( absoluteConfigPath ) ) . href + '/'
await ctx . plugin ( Loader )
2026-07-15 11:51:29 +08:00
ctx . loader . builtins . include = Include
2026-07-04 16:43:06 +08:00
await ctx . loader . create ( {
2026-07-15 11:51:29 +08:00
name : 'cordis:include' ,
2026-07-22 10:55:16 +08:00
config : {
path : pathToFileURL ( absoluteConfigPath ) . href ,
. . . patches !== undefined && patches . length > 0 ? { patches } : { } ,
} ,
2026-07-04 16:43:06 +08:00
} )
await ctx . loader . await ( )
assertEntriesLoaded ( ctx , binName )
return ctx
}
2026-07-22 10:55:16 +08:00
/** 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 path to the harness source
* checkout the running bin was launched from, so the agent knows where its own
* source lives (the self-referential `dsh-tool-cordis` toolset reads and edits
* it). 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 : ` Your own source code is the checkout at ${ sourceRoot } ; you can read it there to learn how dsh works and how to extend it. ` ,
} )
}