|
|
@@ -2,167 +2,45 @@
|
|
|
/**
|
|
|
* The `dsh-acp-agent` bin: boot the ACP server from a leaf `cordis.yml` that
|
|
|
* loads the {@link @deepseek-ai/dsh-acp-agent} app plugin (plus an LLM adapter
|
|
|
- * and a bash executor), speaking ACP JSON-RPC on stdio.
|
|
|
- *
|
|
|
- * Owns the ACP-specific boot glue the example's `start.ts` once held:
|
|
|
- * - `.env` loading (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`) — SKIPPED in
|
|
|
- * snapshot REPLAY so a stray key can never trigger a live model call.
|
|
|
- * - snapshot-mode config selection: `DSH_SNAPSHOT=replay` swaps the given
|
|
|
- * `cordis.yml` for its sibling `cordis.snapshot.yml` (the keyless replay
|
|
|
- * tree: `llm-replay` in place of `llm-deepseek`).
|
|
|
- * - the stdin-dispose lifecycle: in a snapshot run the harness closes stdin
|
|
|
- * when done, so dispose the context (flushing persistence) and exit cleanly.
|
|
|
+ * and a bash executor), speaking ACP JSON-RPC on stdio. The shared boot glue —
|
|
|
+ * `.env` loading, the fail-loud Loader guards, snapshot-aware config
|
|
|
+ * resolution, the settle-the-tree boot sequence — lives in
|
|
|
+ * {@link @deepseek-ai/dsh-app-boot}; this bin owns only the ACP-specific
|
|
|
+ * lifecycle:
|
|
|
+ *
|
|
|
+ * - `.env` loading is SKIPPED in snapshot REPLAY so a stray key can never
|
|
|
+ * trigger a live model call.
|
|
|
+ * - `DSH_SNAPSHOT=replay` swaps the given `cordis.yml` for its sibling
|
|
|
+ * `cordis.snapshot.yml` (the keyless replay tree: `llm-replay` in place of
|
|
|
+ * `llm-deepseek`).
|
|
|
+ * - In a snapshot run the harness closes stdin when done, so dispose the
|
|
|
+ * context (flushing persistence) and exit cleanly. In a normal editor
|
|
|
+ * session stdin stays open for the connection's lifetime (the editor kills
|
|
|
+ * the process), so the EOF handler never fires.
|
|
|
*
|
|
|
* IMPORTANT: stdout is the ACP JSON-RPC channel. This bin writes diagnostics to
|
|
|
- * STDERR only; the app plugin loads no stdout logger. A stray stdout write
|
|
|
- * corrupts the protocol frames.
|
|
|
+ * STDERR only (the app plugin loads no stdout logger, and the shared guards
|
|
|
+ * write to stderr); a stray stdout write corrupts the protocol frames.
|
|
|
*
|
|
|
* Usage: `dsh-acp-agent [path-to-cordis.yml]` (default `./cordis.yml`).
|
|
|
*
|
|
|
* @module @deepseek-ai/dsh-acp-agent/bin
|
|
|
*/
|
|
|
|
|
|
-import { pathToFileURL } from 'node:url'
|
|
|
-import { basename, dirname, resolve } from 'node:path'
|
|
|
-import { Context } from 'cordis'
|
|
|
-import Loader from '@cordisjs/plugin-loader'
|
|
|
-
|
|
|
-/**
|
|
|
- * Resolve the config to boot, honoring snapshot REPLAY. Given the requested
|
|
|
- * path, replay mode swaps a `cordis.yml` basename for `cordis.snapshot.yml` in
|
|
|
- * the SAME directory (the keyless replay tree). Other modes use the path as-is.
|
|
|
- * Returns an absolute path resolved from the cwd.
|
|
|
- */
|
|
|
-export function resolveConfigPath(configPath: string, snapshotMode: string | undefined): string {
|
|
|
- const absolute = resolve(process.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 `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` from a gitignored `.env` in the
|
|
|
- * cwd (Node native). Diagnostics go to STDERR (stdout is the protocol). In
|
|
|
- * REPLAY mode the caller skips this entirely — replay must never reach the
|
|
|
- * network, so a present `.env` must not enable a live call.
|
|
|
- */
|
|
|
-function loadEnv(): void {
|
|
|
- try {
|
|
|
- process.loadEnvFile(resolve(process.cwd(), '.env'))
|
|
|
- } catch (error) {
|
|
|
- if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
|
|
|
- process.stderr.write(`dsh-acp-agent: failed to load .env: ${String(error)}\n`)
|
|
|
- }
|
|
|
- // ENOENT (no .env) is fine — rely on the ambient environment.
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-/**
|
|
|
- * Make a load failure fail loud with a clear message on stderr. Covers the
|
|
|
- * failure path the entry-tree check below cannot: when the include's
|
|
|
- * `[Service.init]` throws (e.g. a config FILE missing in a real directory), the
|
|
|
- * cordis Loader surfaces it as an unhandled promise rejection AFTER `boot()`
|
|
|
- * resolves — `loader.await()` does NOT rethrow it (`EntryTree.await()` uses
|
|
|
- * `Promise.allSettled`, which swallows rejections). Node's default handler
|
|
|
- * already exits non-zero on an unhandled rejection, so this does not change the
|
|
|
- * exit code; it replaces the noisy stack dump with a single labelled line (on
|
|
|
- * STDERR — stdout is the ACP JSON-RPC channel) and guarantees `process.exit(1)`.
|
|
|
- * Install before `boot()`.
|
|
|
- */
|
|
|
-export function installFailLoud(): void {
|
|
|
- process.on('unhandledRejection', (err: unknown) => {
|
|
|
- process.stderr.write(`dsh-acp-agent: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
|
|
|
- process.exit(1)
|
|
|
- })
|
|
|
-}
|
|
|
+import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
|
|
|
|
|
|
-/**
|
|
|
- * After the tree settles, assert every loader entry actually started. This is
|
|
|
- * the load-bearing guard against the SILENT-exit-0 bug: a plugin module that
|
|
|
- * fails to IMPORT (e.g. a config path in a non-existent directory) is caught and
|
|
|
- * only LOGGED by the cordis Loader (`entry._init`), leaving the entry with no
|
|
|
- * `fiber` and producing no rejection — so the process would otherwise exit 0. A
|
|
|
- * started entry has a `fiber`; throw on any entry still missing one so `boot()`
|
|
|
- * rejects.
|
|
|
- *
|
|
|
- * A `disabled` entry is the one legitimate fiber-less state: `Entry.refresh()`
|
|
|
- * deliberately skips `init()` for it, so it settles without a fiber by design —
|
|
|
- * a valid "plugin turned off" config, not a failed import. Exclude it.
|
|
|
- */
|
|
|
-function assertEntriesLoaded(ctx: Context): 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(`dsh-acp-agent: plugin(s) failed to load: ${names} (see the error(s) logged above)`)
|
|
|
- }
|
|
|
-}
|
|
|
+const NAME = 'dsh-acp-agent'
|
|
|
|
|
|
-/**
|
|
|
- * Boot the Loader against `absoluteConfigPath`. The include is handed the
|
|
|
- * config's ABSOLUTE `file://` URL as its `path`, so resolution never depends on
|
|
|
- * `ctx.baseUrl` (an absolute URL ignores the base) and can never fall back to
|
|
|
- * the cwd. `baseUrl` is still pinned to the config's directory so the config's
|
|
|
- * OWN relative plugin/include paths resolve against it. Returns the root context
|
|
|
- * once the whole tree has settled.
|
|
|
- *
|
|
|
- * The `await ctx.loader.await()` is load-bearing: `loader.create()` returns once
|
|
|
- * the include ENTRY is registered, but the include then loads its child plugins
|
|
|
- * asynchronously. Without awaiting the tree, `boot()` would resolve while the ACP
|
|
|
- * bridge is still mounting — the process would have no stdin handle attached yet
|
|
|
- * and could exit 0 silently. Awaiting keeps the process alive until the bridge
|
|
|
- * is up.
|
|
|
- *
|
|
|
- * `loader.await()` does NOT rethrow load errors (`EntryTree.await()` uses
|
|
|
- * `Promise.allSettled`), so failures are surfaced two ways: a plugin that fails
|
|
|
- * to IMPORT leaves an entry with no fiber, caught here by
|
|
|
- * {@link assertEntriesLoaded} (this `boot()` rejects); a plugin whose init THROWS
|
|
|
- * surfaces as an unhandled rejection caught by {@link installFailLoud} (installed
|
|
|
- * by `main()` before this runs). Together any load failure exits non-zero.
|
|
|
- *
|
|
|
- * Bare plugin specifiers in the config (`@deepseek-ai/dsh-*`, npm packages) are
|
|
|
- * resolved by the cordis Loader's internal module loader, which is only active
|
|
|
- * under `node --expose-internals`. The `demo:acp` script runs under tsx (whose
|
|
|
- * tsconfig `paths` map resolves the workspace plugins instead), but a consumer
|
|
|
- * running the built bin under plain node must pass `--expose-internals` so the
|
|
|
- * Loader resolves the config's plugins from the config directory rather than
|
|
|
- * relative to its own module.
|
|
|
- */
|
|
|
-export async function boot(absoluteConfigPath: string): Promise<Context> {
|
|
|
- const ctx = new Context()
|
|
|
- ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
|
|
|
- await ctx.plugin(Loader)
|
|
|
- await ctx.loader.create({
|
|
|
- name: '@cordisjs/plugin-include',
|
|
|
- config: { path: pathToFileURL(absoluteConfigPath).href },
|
|
|
+/* v8 ignore start -- thin self-executing composition over the unit-tested
|
|
|
+ dsh-app-boot helpers; exercised end-to-end by the snapshot suite and the
|
|
|
+ built-bin smoke */
|
|
|
+installFailLoud(NAME)
|
|
|
+const snapshotMode = process.env['DSH_SNAPSHOT']
|
|
|
+if (snapshotMode !== 'replay') loadEnv(NAME)
|
|
|
+const ctx = await boot(NAME, resolveConfigPath(process.argv[2] ?? './cordis.yml', snapshotMode))
|
|
|
+if (snapshotMode !== undefined) {
|
|
|
+ process.stdin.on('end', () => {
|
|
|
+ void ctx.fiber.dispose().then(() => { process.exit(0) })
|
|
|
})
|
|
|
- await ctx.loader.await()
|
|
|
- assertEntriesLoaded(ctx)
|
|
|
- return ctx
|
|
|
}
|
|
|
-
|
|
|
-/**
|
|
|
- * Entry point. Installs the fail-loud guard, selects the config (snapshot-aware),
|
|
|
- * loads `.env` outside replay, boots, and — in a snapshot run — disposes the
|
|
|
- * context on stdin EOF so the session log is fully flushed before exit and the
|
|
|
- * harness's `waitForExit` resolves. In a normal editor session stdin stays open
|
|
|
- * for the connection's lifetime (the editor kills the process), so the EOF
|
|
|
- * handler never fires.
|
|
|
- */
|
|
|
-export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
|
|
|
- installFailLoud()
|
|
|
- const snapshotMode = process.env.DSH_SNAPSHOT
|
|
|
- const configPath = resolveConfigPath(argv[0] ?? './cordis.yml', snapshotMode)
|
|
|
- if (snapshotMode !== 'replay') loadEnv()
|
|
|
- const ctx = await boot(configPath)
|
|
|
- if (snapshotMode !== undefined) {
|
|
|
- process.stdin.on('end', () => {
|
|
|
- void ctx.fiber.dispose().then(() => { process.exit(0) })
|
|
|
- })
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-/* v8 ignore start -- top-level CLI invocation; the testable core is
|
|
|
- resolveConfigPath()/boot()/main(), driven by the keyless snapshot + Loader-path tests */
|
|
|
-await main()
|
|
|
/* v8 ignore stop */
|