Browse Source

feat(cli): launch dsh source through the tsx ESM hook

Node 26.0.0 removed --experimental-transform-types, so the native
source-launch chain cannot start anywhere on that line, and strip-only
mode rejects the vendored syntax (parameter properties, decorators,
runtime enums/namespaces). Switch bin/dsh, the root dsh/demo:tui/
demo:web scripts, and the Code Mode TUI overlay to node --import
tsx/esm: one launch vector across the whole engines range, ~0.4s faster
than the full tsx default (the CJS hook stays off; the graph is
ESM-only).

Delete scripts/tspath-loader.ts and apps/cli/src/tsconfig-paths-loader.ts:
tsx owns both transformation and tsconfig paths projection. Add
dsh-source-launch-smoke to the node-compat gates so the 22.19/26 matrix
executes the real launch vector; no CI job did, which is how the Node 26
breakage shipped silently.

Supersedes the native-TypeScript-source-launch Agent Note (new note
records the profiling evidence and rejected alternatives).
kingwl 1 tháng trước cách đây
mục cha
commit
aebf9c863b

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md
-2026-07-28-dsh-native-typescript-source-launch.md: 019389f3e5e9229f4359bbd58c95dbb2f14eb24b
-2026-07-28-dsh-native-typescript-source-launch.zh.md: 2cfff25d228e67ac85a9bc9087fa09ddb64213a0
+2026-07-28-dsh-native-typescript-source-launch.md: 1ba1dd2663038ad7c49af71f8b428245f7fa3e2b
+2026-07-28-dsh-native-typescript-source-launch.zh.md: 02f84f34820469ad9e810ae17d79d3fe12b0cd4c

+ 2 - 0
.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md

@@ -4,6 +4,8 @@ Status: implemented
 
 English | [中文](2026-07-28-dsh-native-typescript-source-launch.zh.md)
 
+> The Node-native launch vector is superseded by [dsh source launch through the tsx ESM hook](2026-07-29-dsh-source-launch-tsx-esm.md): Node 26.0.0 removed `--experimental-transform-types`, and the paths loader described here is deleted. The Cordis-config declaration gate (`verify-cordis-config`), the app-boot fail-loud plugin diagnostic, and the vendored `import type` marks remain current.
+
 ## Problem
 
 The `dsh` source entry point originally used `tsx` to run `apps/cli/src/bin.ts`, with the same third-party loader implicitly handling both TypeScript transformation and the root tsconfig's `paths` resolution. With Node handling TypeScript natively, it does not apply tsconfig path mappings; resolving through package exports would instead mix potentially stale or nonexistent `lib/` artifacts into the source launch.

+ 2 - 0
.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md

@@ -4,6 +4,8 @@ Status: implemented
 
 [English](2026-07-28-dsh-native-typescript-source-launch.md) | 中文
 
+> Node 原生启动向量已被 [dsh 通过 tsx ESM hook 源码启动](2026-07-29-dsh-source-launch-tsx-esm.md) 取代:Node 26.0.0 移除了 `--experimental-transform-types`,本文描述的 paths loader 已删除。Cordis 配置声明门禁(`verify-cordis-config`)、app-boot 的 fail-loud 插件诊断以及 vendor 中的 `import type` 标注仍然有效。
+
 ## 问题
 
 `dsh` 源码入口原本使用 `tsx` 运行 `apps/cli/src/bin.ts`,TypeScript 转换和根 tsconfig 的 `paths` 解析都由同一个第三方 loader 隐式处理。改由 Node 原生处理 TypeScript 后,Node 不会应用 tsconfig 路径映射;如果改为通过包导出解析,源码启动会混入可能陈旧或不存在的 `lib/` 产物。

+ 6 - 0
.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md
+2026-07-29-dsh-source-launch-tsx-esm.md: 93fbb248b45efde37d5fbdb1ec4b812ab3332088
+2026-07-29-dsh-source-launch-tsx-esm.zh.md: 48f410bd846e5808cc95180279348a0ac5ba1c95

+ 38 - 0
.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md

@@ -0,0 +1,38 @@
+# Agent Note: dsh source launch through the tsx ESM hook
+
+Status: implemented
+
+English | [中文](2026-07-29-dsh-source-launch-tsx-esm.zh.md)
+
+> Supersedes [native TypeScript source launch](2026-07-28-dsh-native-typescript-source-launch.md): Node removed the capability that decision was built on.
+
+## Problem
+
+The [native source-launch decision](2026-07-28-dsh-native-typescript-source-launch.md) ran `apps/cli/src/bin.ts` under `node --experimental-transform-types` with a resolve-only paths loader, so Node owned TypeScript transformation. Node 26.0.0 removed `--experimental-transform-types` (the process rejects the flag with `bad option`), keeping only strip mode, and strip mode rejects syntax this source graph requires: vendored Cordis parameter properties (`constructor(private ctx: Context)`), the `@Inject` decorators in `vendor/hmr`, and runtime enums/namespaces throughout `vendor/` and `packages/workflow`. The repository's engines range (`^22.19.0 || >=24.0.0`) includes Node 26, so the native launch chain could not start at all there — and no CI job executed the real launch vector, so the incompatibility shipped silently.
+
+Startup latency also mattered: the off-thread `module.register()` hooks worker serialized every resolution across threads (~440ms of `makeSyncRequest` wait during TUI boot), and the full tsx default (`--import tsx`) pays ~0.4s in its CJS hook's resolution amplification.
+
+## Decision
+
+The `dsh` TUI, Web, and headless source launches run `node --import tsx/esm`: tsx's ESM-only hook owns both TypeScript transformation and tsconfig `paths` projection. `bin/dsh`, the root `dsh`/`demo:tui`/`demo:web` scripts, and the Code Mode TUI overlay use the same vector; `bin/dsh` references the hook and tsconfig by absolute checkout paths (bare `tsx/esm` does not resolve from an arbitrary cwd) and pins `TSX_TSCONFIG_PATH` to the root tsconfig. The CJS hook stays off because the CLI source graph is ESM-only; measured TUI time-to-banner is ~0.7s versus ~1.1s under the full tsx default and ~0.75s under the removed native chain.
+
+`scripts/tspath-loader.ts` and `apps/cli/src/tsconfig-paths-loader.ts` are deleted. With them went the loader's runtime rule of mapping a workspace import only for declared runtime dependencies — tsx applies the `paths` map unconditionally. Declaration completeness now rests on the static gates alone: `verify-cordis-config` for configured bare plugins, and workspace constraints for manifests. (That runtime rule found real bugs: `dsh-plan-mode` and `dsh-tool-tasks` imported `@deepseek-ai/dsh-llm` while declaring it only in devDependencies; fixed alongside this change.)
+
+The node-compat CI matrix (Node 22.19 and 26) gains `dsh-source-launch-smoke` (`apps/cli/tests/source-launch.compat.spec.ts`): a keyless piped-stdio launch of the exact production vector asserting the non-zero-exit TTY refusal. Any future Node change to module hooks or TypeScript handling turns this gate red instead of breaking developers' `pnpm dsh`.
+
+## Alternatives considered
+
+**Keep the native chain on Node ≤25 and branch by version.** Rejected: two transformation semantics (amaro versus esbuild) diverge on edge syntax, the launcher grows version probing, and the node-compat matrix must cover both paths — heavy maintenance for an experimental flag that already changed under us. amaro also rejects the `@Inject` decorators `vendor/hmr` uses, so the native path could not boot the shipped default TUI config anyway.
+
+**Make the source graph erasable-only so Node 26 strip mode accepts it.** Rejected: parameter properties and value namespaces pervade vendored Cordis/cosmokit/loader/schemastery; rewriting them is unbounded churn re-applied on every vendor sync.
+
+**A repo-owned in-thread loader (`module.registerHooks()` + esbuild or `@swc/core` transform).** Rejected for now: prototypes measured ~0.45s (esbuild path untested end-to-end; SWC breaks on `vendor/hmr`'s decorator + namespace merge in both decorator modes), but it means owning transform correctness and a resolve hook that tsx already provides. Revisit only if the ~0.3s gap becomes a real cost; the profiling evidence lives in the PR discussion.
+
+**Run built `lib/` for Node 26 and keep native for 24.** Rejected: loses the zero-build development loop on the newest Node line and mixes source and artifact planes.
+
+## Consequences
+
+- One launch vector across the whole engines range, including future Node lines that change native TypeScript support; the smoke gate enforces it per matrix line.
+- TypeScript transformation is delegated to tsx/esbuild again, reversing the prior note's goal of proving Node-native transformation; that goal is unreachable while vendored sources use non-erasable syntax and Node ships no transform mode.
+- The runtime declared-dependency enforcement in source launches is gone; undeclared workspace imports now surface only through static gates or built-mode resolution failures.
+- Startup improves ~0.4s over the full tsx default (`demo:headless` and ACP keep `--import tsx`; their graphs were not audited for CJS-hook dependence and their launch latency is not on the interactive path).

+ 38 - 0
.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md

@@ -0,0 +1,38 @@
+# Agent Note: dsh 通过 tsx ESM hook 源码启动
+
+Status: implemented
+
+[English](2026-07-29-dsh-source-launch-tsx-esm.md) | 中文
+
+> 取代[原生 TypeScript 源码启动](2026-07-28-dsh-native-typescript-source-launch.md):Node 移除了该决策所依赖的能力。
+
+## 问题
+
+[原生源码启动决策](2026-07-28-dsh-native-typescript-source-launch.md)让 `apps/cli/src/bin.ts` 在 `node --experimental-transform-types` 下运行,配合一个只做解析的 paths loader,由 Node 负责 TypeScript 转换。Node 26.0.0 移除了 `--experimental-transform-types`(进程以 `bad option` 拒绝该 flag),只保留 strip 模式,而 strip 模式无法接受这个源码图必需的语法:vendor Cordis 中的参数属性(`constructor(private ctx: Context)`)、`vendor/hmr` 中的 `@Inject` 装饰器,以及遍布 `vendor/` 与 `packages/workflow` 的运行时 enum/namespace。仓库的 engines 范围(`^22.19.0 || >=24.0.0`)包含 Node 26,因此原生启动链在其上完全无法启动——且没有任何 CI 任务执行过真实启动向量,这一不兼容悄然发布。
+
+启动延迟同样是问题:off-thread 的 `module.register()` hooks worker 把每次解析都跨线程序列化(TUI 启动期间约 440ms 的 `makeSyncRequest` 等待),而完整 tsx 默认形态(`--import tsx`)的 CJS hook 解析放大要多付约 0.4s。
+
+## 决策
+
+`dsh` 的 TUI、Web 与无头源码启动运行 `node --import tsx/esm`:由 tsx 的 ESM-only hook 同时负责 TypeScript 转换与 tsconfig `paths` 投影。`bin/dsh`、根目录的 `dsh`/`demo:tui`/`demo:web` 脚本以及 Code Mode TUI overlay 使用同一向量;`bin/dsh` 以 checkout 的绝对路径引用 hook 与 tsconfig(裸的 `tsx/esm` 无法从任意 cwd 解析),并将 `TSX_TSCONFIG_PATH` 固定到根 tsconfig。CJS hook 保持关闭,因为 CLI 源码图是纯 ESM;实测 TUI 到 banner 约 0.7s,对比完整 tsx 默认形态约 1.1s、已移除的原生链约 0.75s。
+
+`scripts/tspath-loader.ts` 与 `apps/cli/src/tsconfig-paths-loader.ts` 已删除。随之消失的还有该 loader "仅为已声明运行时依赖映射 workspace import" 的运行时规则——tsx 无条件应用 `paths` 映射。声明完整性现在仅由静态门禁保障:配置的裸插件走 `verify-cordis-config`,manifest 走 workspace constraints。(该运行时规则确实发现过真实缺陷:`dsh-plan-mode` 与 `dsh-tool-tasks` 导入 `@deepseek-ai/dsh-llm` 却只声明在 devDependencies;已随本变更修复。)
+
+node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(`apps/cli/tests/source-launch.compat.spec.ts`):以精确的生产启动向量做 keyless 管道 stdio 启动,断言非零退出的 TTY 拒绝。未来 Node 对模块 hook 或 TypeScript 处理的任何改动都会让该门禁变红,而不是破坏开发者的 `pnpm dsh`。
+
+## 备选方案
+
+**在 Node ≤25 保留原生链并按版本分叉。** 拒绝:两套转换语义(amaro 与 esbuild)在边缘语法上会分歧,启动器要加版本探测,node-compat 矩阵要覆盖两条路径——为一个已经变动过的 experimental flag 付出沉重维护。而且 amaro 也不支持 `vendor/hmr` 使用的 `@Inject` 装饰器,原生路径本来就无法启动随附的默认 TUI 配置。
+
+**把源码图改成 erasable-only 以适配 Node 26 strip 模式。** 拒绝:参数属性与值 namespace 遍布 vendor 的 Cordis/cosmokit/loader/schemastery;改写是无界 churn,且每次 vendor sync 都要重做。
+
+**仓库自有的同线程 loader(`module.registerHooks()` + esbuild 或 `@swc/core` 转换)。** 暂拒:原型实测约 0.45s(esbuild 路径未端到端验证;SWC 在 `vendor/hmr` 的装饰器 + namespace 合并上两种装饰器模式都会崩),但意味着自行负责转换正确性和一个 tsx 已经提供的 resolve hook。仅当约 0.3s 的差距成为真实成本时再重启;profiling 证据在 PR 讨论中。
+
+**Node 26 运行构建产物 `lib/`,24 保留原生。** 拒绝:在最新 Node 版本线上失去零构建开发循环,且混淆源码面与产物面。
+
+## 结果
+
+- 整个 engines 范围(包括未来改变原生 TypeScript 支持的 Node 版本线)只有一个启动向量;冒烟门禁按矩阵行强制执行。
+- TypeScript 转换重新委托给 tsx/esbuild,逆转了前一篇 note "证明 Node 原生转换可用" 的目标;在 vendor 源码使用不可擦除语法且 Node 不再提供 transform 模式的情况下,该目标不可达。
+- 源码启动中的运行时依赖声明强制不复存在;未声明的 workspace import 现在只能通过静态门禁或构建模式的解析失败暴露。
+- 启动相比完整 tsx 默认形态快约 0.4s(`demo:headless` 与 ACP 保持 `--import tsx`:其依赖图未就 CJS hook 依赖性做审计,且其启动延迟不在交互路径上)。

+ 1 - 1
AGENTS.md

@@ -89,7 +89,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
 ## Conventions
 
 - Every npm package is `@deepseek-ai/dsh-<name>`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package.
-- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). CLI source-launch code and every module it reaches must support Node `--experimental-transform-types`: use `import type` for erased bindings and native ESM exports, with no TSX/JSX or tsx/esbuild-only transforms. TUI/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces the [source-launch contract](.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md).
+- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only shapes) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). TUI/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it.
 - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer.
 - **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. If a package has no plausible relationship, an explained empty companion is correct ([package contract](packages/AGENTS.md)).
 - **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns.

+ 2 - 2
apps/cli/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/cli/README.md
-README.md: 13a80b1d0e0105bc0c30c019209b2e0295b7bef9
-README.zh.md: 2a5d9c15c57351ef03ebe60a5cdf90f0d0c8f18b
+README.md: 3d029e5c97647526357c34df1d87edc904b92e33
+README.zh.md: 70930fa7035effad3ab65926e474fa53883546d7

+ 1 - 1
apps/cli/README.md

@@ -26,6 +26,6 @@ Symlink the source-running launcher onto your PATH; it resolves the checkout thr
 ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
 ```
 
-Source launches run `apps/cli/src/bin.ts` through Node's `--experimental-transform-types`; `scripts/tspath-loader.ts` only projects tsconfig `paths` into module resolution and does not transform code. Every module reachable from the CLI source entry follows Node's transform-types contract: erased bindings use `import type`, exports use native ESM, and the graph contains no TSX/JSX or transforms that only tsx/esbuild provides. The loader reads `TSX_TSCONFIG_PATH` when set (relative paths resolve from the invoking cwd), otherwise the repository's root tsconfig, using the root TypeScript development tool rather than an application dependency. It maps a workspace import only for a package self-reference or a declared runtime dependency. The TUI configs resolve bare plugins through `examples/package.json`, while the Web/headless `cordis.yml` resolves them through this package's `dependencies`; `verify-cordis-config` requires every configured bare plugin to be declared, while allowing unrelated dependencies.
+Source launches run `apps/cli/src/bin.ts` through tsx's ESM-only hook (`node --import tsx/esm`), which transforms TypeScript and projects the root tsconfig `paths` map into module resolution. Node's native TypeScript modes are not used: Node 26 removed `--experimental-transform-types`, and strip-only mode rejects syntax the source graph relies on (vendored parameter properties, decorators, runtime enums/namespaces). The CJS hook stays off because the source graph is ESM-only and the CJS resolver adds ~0.4s of startup. `bin/dsh` pins `TSX_TSCONFIG_PATH` to the checkout's root tsconfig so resolution is cwd-independent, and the `dsh-source-launch-smoke` node-compat gate runs this exact launch vector on every supported Node line. tsx applies the `paths` map without checking dependency declarations, so declaration completeness rests on the static gates: the TUI configs resolve bare plugins through `examples/package.json`, the Web/headless `cordis.yml` through this package's `dependencies`, and `verify-cordis-config` requires every configured bare plugin to be declared, while allowing unrelated dependencies.
 
 `pnpm run dsh` runs the same entry from the repo root and forwards arguments directly, for example `pnpm run dsh -p "task"`. The built form (`lib/bin.js`, via `pnpm run build`) boots the same config under plain Node.

+ 1 - 1
apps/cli/README.zh.md

@@ -26,6 +26,6 @@ Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将
 ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
 ```
 
-源码启动会通过 Node 的 `--experimental-transform-types` 运行 `apps/cli/src/bin.ts`;`scripts/tspath-loader.ts` 只会将 tsconfig 的 `paths` 映射投射到模块解析中,而不会转换代码。从 CLI 源码入口可达的每个模块都遵守 Node transform-types 契约:会被擦除的绑定使用 `import type`,export 使用原生 ESM,整个依赖图不含 TSX/JSX,也不依赖仅由 tsx/esbuild 提供的转换。设置 `TSX_TSCONFIG_PATH` 时,loader 会读取该路径(相对路径从调用方的 cwd 解析),否则读取仓库根 tsconfig;它使用根目录的 TypeScript 开发工具,而不是应用依赖。仅当 workspace import 是包自身引用或已声明的运行时依赖时,loader 才会映射该 import。TUI 配置通过 `examples/package.json` 解析裸插件,Web/无头 `cordis.yml` 通过本包的 `dependencies` 解析;`verify-cordis-config` 要求每个已配置的裸插件均已声明,同时允许存在无关依赖。
+源码启动会通过 tsx 的 ESM-only hook(`node --import tsx/esm`)运行 `apps/cli/src/bin.ts`,由它转换 TypeScript 并将根 tsconfig 的 `paths` 映射投射到模块解析中。不使用 Node 原生 TypeScript 模式:Node 26 移除了 `--experimental-transform-types`,而 strip-only 模式无法接受源码图依赖的语法(vendor 中的参数属性、装饰器、运行时 enum/namespace)。CJS hook 保持关闭,因为源码图是纯 ESM,而 CJS 解析器会增加约 0.4s 启动耗时。`bin/dsh` 将 `TSX_TSCONFIG_PATH` 固定到 checkout 的根 tsconfig,使解析与 cwd 无关;node-compat 门禁 `dsh-source-launch-smoke` 会在每条受支持的 Node 版本线上运行这一精确启动向量。tsx 应用 `paths` 映射时不检查依赖声明,声明完整性由静态门禁保障:TUI 配置通过 `examples/package.json` 解析裸插件,Web/无头 `cordis.yml` 通过本包的 `dependencies` 解析;`verify-cordis-config` 要求每个已配置的裸插件均已声明,同时允许存在无关依赖。
 
 `pnpm run dsh` 从仓库根目录运行同一入口并直接转发参数,例如 `pnpm run dsh -p "task"`。构建形式(`lib/bin.js`,通过 `pnpm run build`)会在普通 Node 下启动同一配置。

+ 0 - 216
apps/cli/src/tsconfig-paths-loader.ts

@@ -1,216 +0,0 @@
-/**
- * Node module resolve hook for the `dsh` source launcher. It projects the root
- * tsconfig `paths` map into Node resolution while leaving all TypeScript syntax
- * handling to Node's native transform-types runtime.
- * @module @deepseek-ai/dsh/tsconfig-paths-loader
- */
-
-import { readFile, stat } from 'node:fs/promises'
-import { dirname, extname, join, resolve } from 'node:path'
-import { fileURLToPath, pathToFileURL } from 'node:url'
-import type { ResolveHookContext, ResolveFnOutput } from 'node:module'
-import ts from 'typescript'
-
-interface LoaderData {
-  tsconfigPath: string
-}
-
-interface PackageManifest {
-  name?: string
-  dependencies?: Record<string, string>
-  optionalDependencies?: Record<string, string>
-  peerDependencies?: Record<string, string>
-}
-
-interface PathRule {
-  pattern: string
-  prefix: string
-  suffix: string
-  targets: readonly string[]
-}
-
-interface PathsCompilerOptions {
-  readonly baseUrl?: string
-  readonly paths?: ts.MapLike<string[]>
-  readonly pathsBasePath?: string
-}
-
-// Node's native TypeScript transform cannot parse JSX, so `.tsx` is excluded.
-const SOURCE_EXTENSIONS = ['.ts', '.mts', '.cts'] as const
-
-/**
- * Resolve package imports through one parsed tsconfig paths table.
- *
- * Manifest reads are process-scoped and memoized by path. Only matched source
- * aliases enter the cache, bounding it to directories participating in source
- * resolution.
- */
-export class TsconfigPathsResolver {
-  private readonly rules: readonly PathRule[]
-  private readonly configDirectory: string
-  private readonly manifests = new Map<string, Promise<PackageManifest | undefined>>()
-
-  private constructor(configDirectory: string, paths: ts.MapLike<string[]>) {
-    this.configDirectory = configDirectory
-    this.rules = Object.entries(paths)
-      .map(([pattern, targets]) => {
-        const wildcard = pattern.indexOf('*')
-        return {
-          pattern,
-          prefix: wildcard === -1 ? pattern : pattern.slice(0, wildcard),
-          suffix: wildcard === -1 ? '' : pattern.slice(wildcard + 1),
-          targets,
-        }
-      })
-      .sort((left, right) => {
-        const leftExact = left.pattern.includes('*') ? 0 : 1
-        const rightExact = right.pattern.includes('*') ? 0 : 1
-        return rightExact - leftExact || right.prefix.length - left.prefix.length || right.suffix.length - left.suffix.length
-      })
-  }
-
-  /**
-   * Parse a tsconfig including its `extends` chain.
-   * @param tsconfigPath Absolute tsconfig path supplying `compilerOptions.paths`.
-   * @returns A resolver backed by that path table.
-   */
-  static create(tsconfigPath: string): TsconfigPathsResolver {
-    let unrecoverable: ts.Diagnostic | undefined
-    const parsed = ts.getParsedCommandLineOfConfigFile(tsconfigPath, {}, {
-      ...ts.sys,
-      onUnRecoverableConfigFileDiagnostic(diagnostic) { unrecoverable = diagnostic },
-    })
-    if (parsed === undefined) {
-      const detail = unrecoverable === undefined
-        ? 'unknown configuration error'
-        : ts.flattenDiagnosticMessageText(unrecoverable.messageText, '\n')
-      throw new Error(`dsh source loader could not parse ${tsconfigPath}: ${detail}`)
-    }
-    const options = parsed.options as PathsCompilerOptions
-    const paths = options.paths
-    if (paths === undefined) throw new Error(`dsh source loader requires compilerOptions.paths in ${tsconfigPath}`)
-    const configDirectory = options.baseUrl ?? options.pathsBasePath ?? dirname(tsconfigPath)
-    return new TsconfigPathsResolver(configDirectory, paths)
-  }
-
-  /**
-   * Resolve one bare package specifier to a source file when the importing
-   * package (or config-directory owner) declares that package at runtime.
-   * @param specifier Module specifier passed to Node.
-   * @param parentURL Importing file or Loader config-directory URL.
-   * @returns Source file URL, or `undefined` when normal Node resolution owns the request.
-   */
-  async resolve(specifier: string, parentURL: string | undefined): Promise<string | undefined> {
-    const packageName = packageNameFromSpecifier(specifier)
-    if (packageName === undefined || parentURL === undefined || !parentURL.startsWith('file:')) return undefined
-    const matched = this.match(specifier)
-    if (matched === undefined) return undefined
-    const configParent = parentURL.endsWith('/')
-    const parentPath = fileURLToPath(parentURL)
-    const startDirectory = configParent ? parentPath : dirname(parentPath)
-    if (!await this.isDeclaredRuntimeDependency(startDirectory, packageName, configParent)) return undefined
-
-    for (const target of matched.targets) {
-      const substituted = target.replace('*', matched.wildcard)
-      const candidate = await existingSourcePath(resolve(this.configDirectory, substituted))
-      if (candidate !== undefined) return pathToFileURL(candidate).href
-    }
-    return undefined
-  }
-
-  private match(specifier: string): { targets: readonly string[]; wildcard: string } | undefined {
-    for (const rule of this.rules) {
-      if (!rule.pattern.includes('*')) {
-        if (specifier === rule.pattern) return { targets: rule.targets, wildcard: '' }
-        continue
-      }
-      if (!specifier.startsWith(rule.prefix) || !specifier.endsWith(rule.suffix)) continue
-      const wildcard = specifier.slice(rule.prefix.length, specifier.length - rule.suffix.length)
-      return { targets: rule.targets, wildcard }
-    }
-    return undefined
-  }
-
-  private async isDeclaredRuntimeDependency(
-    startDirectory: string,
-    packageName: string,
-    searchAncestors: boolean,
-  ): Promise<boolean> {
-    for (let directory = startDirectory; ; directory = dirname(directory)) {
-      const manifest = await this.readManifest(join(directory, 'package.json'))
-      if (manifest !== undefined) {
-        if (declaresRuntimeDependency(manifest, packageName)) return true
-        if (!searchAncestors) return false
-      }
-      const parent = dirname(directory)
-      if (parent === directory) return false
-    }
-  }
-
-  private readManifest(path: string): Promise<PackageManifest | undefined> {
-    let pending = this.manifests.get(path)
-    if (pending !== undefined) return pending
-    pending = readFile(path, 'utf8').then(
-      content => JSON.parse(content) as PackageManifest,
-      (error: unknown) => {
-        if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
-        throw error
-      },
-    )
-    this.manifests.set(path, pending)
-    return pending
-  }
-}
-
-let resolver: TsconfigPathsResolver | undefined
-
-/** Initialize the hook worker from the source-launch preloader. */
-export function initialize(data: LoaderData): void {
-  resolver = TsconfigPathsResolver.create(data.tsconfigPath)
-}
-
-/** Resolve declared workspace packages to source and delegate every other request to Node. */
-export async function resolveHook(
-  specifier: string,
-  context: ResolveHookContext,
-  nextResolve: (specifier: string, context: ResolveHookContext) => Promise<ResolveFnOutput>,
-): Promise<ResolveFnOutput> {
-  const url = await resolver?.resolve(specifier, context.parentURL)
-  return url === undefined ? nextResolve(specifier, context) : { url, shortCircuit: true }
-}
-
-// Node customization hooks discover this exact export name.
-export { resolveHook as resolve }
-
-function packageNameFromSpecifier(specifier: string): string | undefined {
-  if (specifier.startsWith('.') || specifier.startsWith('/') || /^[a-z][a-z+.-]*:/i.test(specifier)) {
-    return undefined
-  }
-  const segments = specifier.split('/')
-  return specifier.startsWith('@')
-    ? segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined
-    : segments[0] || undefined
-}
-
-function declaresRuntimeDependency(manifest: PackageManifest, packageName: string): boolean {
-  return manifest.name === packageName
-    || packageName in (manifest.dependencies ?? {})
-    || packageName in (manifest.optionalDependencies ?? {})
-    || packageName in (manifest.peerDependencies ?? {})
-}
-
-async function existingSourcePath(base: string): Promise<string | undefined> {
-  const extension = extname(base)
-  if (extension === '.tsx') return undefined
-  const candidates = extension === ''
-    ? [base, ...SOURCE_EXTENSIONS.map(extension => `${base}${extension}`), ...SOURCE_EXTENSIONS.map(extension => join(base, `index${extension}`))]
-    : [base]
-  for (const candidate of candidates) {
-    try {
-      if ((await stat(candidate)).isFile()) return candidate
-    } catch (error) {
-      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
-    }
-  }
-  return undefined
-}

+ 36 - 0
apps/cli/tests/source-launch.compat.spec.ts

@@ -0,0 +1,36 @@
+import { fileURLToPath } from 'node:url'
+import { execa } from 'execa'
+import { describe, expect, it } from 'vitest'
+
+/**
+ * Keyless smoke for the SOURCE `dsh` launcher: run `apps/cli/src/bin.ts`
+ * with the exact production launch vector (`node --import tsx/esm`, the same
+ * shape as `bin/dsh` and the root `dsh`/`demo:tui`/`demo:web` scripts) and
+ * assert the piped-stdio TTY refusal. The Node compatibility matrix runs this
+ * WHOLE file, so a Node release changing module hooks or TypeScript handling
+ * breaks this gate instead of every developer's `pnpm dsh`; the built-bin
+ * suite covers the published `lib/` entry, not this source chain.
+ */
+
+const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
+const dshSourceBin = 'apps/cli/src/bin.ts'
+
+describe('dsh SOURCE launcher (node --import tsx/esm)', () => {
+  it('boots the source entry and refuses pipes LOUD (non-zero exit + stderr)', async () => {
+    const result = await execa(process.execPath, ['--import', 'tsx/esm', dshSourceBin], {
+      cwd: repoRoot,
+      input: '',
+      timeout: 25_000,
+      killSignal: 'SIGKILL',
+      reject: false,
+    })
+    if (result.timedOut) {
+      throw new Error(`dsh source launch did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
+    }
+    expect(result.exitCode).not.toBe(0)
+    expect(result.stderr).toContain('requires stdin and stdout to be interactive TTYs')
+    expect(result.stderr).toContain('dsh -p')
+    // The refusal happens before any plugin mounts: stdout stays silent.
+    expect(result.stdout).toBe('')
+  }, 30_000)
+})

+ 0 - 180
apps/cli/tests/tsconfig-paths-loader.spec.ts

@@ -1,180 +0,0 @@
-import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
-import type { ResolveFnOutput, ResolveHookContext } from 'node:module'
-import { tmpdir } from 'node:os'
-import { dirname, join } from 'node:path'
-import { pathToFileURL } from 'node:url'
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import { initialize, resolveHook, TsconfigPathsResolver } from '../src/tsconfig-paths-loader.ts'
-
-class ResolverFixture {
-  readonly root = mkdtempSync(join(tmpdir(), 'dsh-tsconfig-paths-'))
-
-  path(relativePath: string): string {
-    return join(this.root, relativePath)
-  }
-
-  write(relativePath: string, content = 'export {}\n'): string {
-    const path = this.path(relativePath)
-    mkdirSync(dirname(path), { recursive: true })
-    writeFileSync(path, content)
-    return path
-  }
-
-  writeJson(relativePath: string, value: unknown): string {
-    return this.write(relativePath, `${JSON.stringify(value)}\n`)
-  }
-
-  createResolver(paths: Record<string, string[]>): TsconfigPathsResolver {
-    const tsconfigPath = this.writeJson('tsconfig.json', { compilerOptions: { paths } })
-    return TsconfigPathsResolver.create(tsconfigPath)
-  }
-
-  parentURL(relativePath = 'consumer/src/nested/index.ts'): string {
-    return pathToFileURL(this.path(relativePath)).href
-  }
-
-  dispose(): void {
-    rmSync(this.root, { recursive: true, force: true })
-  }
-}
-
-const fixtures: ResolverFixture[] = []
-
-function fixture(): ResolverFixture {
-  const value = new ResolverFixture()
-  fixtures.push(value)
-  return value
-}
-
-afterEach(() => {
-  for (const value of fixtures.splice(0)) value.dispose()
-})
-
-describe('TsconfigPathsResolver', () => {
-  it('orders exact, longer-prefix, and longer-suffix path rules', async () => {
-    const files = fixture()
-    files.writeJson('consumer/package.json', {
-      dependencies: {
-        '@scope/feature-name': '*',
-        '@scope/feature-other': '*',
-        '@scope/plain-suffix': '*',
-      },
-    })
-    files.write('targets/exact.ts')
-    files.write('targets/prefix/other.ts')
-    files.write('targets/generic/feature-other.ts')
-    files.write('targets/suffix/plain.ts')
-    files.write('targets/generic/plain-suffix.ts')
-    const resolver = files.createResolver({
-      '@scope/*': ['./targets/generic/*'],
-      '@scope/*-suffix': ['./targets/suffix/*'],
-      '@scope/feature-*': ['./targets/prefix/*'],
-      '@scope/feature-name': ['./targets/exact.ts'],
-    })
-
-    await expect(resolver.resolve('@scope/feature-name', files.parentURL()))
-      .resolves.toBe(pathToFileURL(files.path('targets/exact.ts')).href)
-    await expect(resolver.resolve('@scope/feature-other', files.parentURL()))
-      .resolves.toBe(pathToFileURL(files.path('targets/prefix/other.ts')).href)
-    await expect(resolver.resolve('@scope/plain-suffix', files.parentURL()))
-      .resolves.toBe(pathToFileURL(files.path('targets/suffix/plain.ts')).href)
-  })
-
-  it('resolves only self-references and runtime dependencies from the nearest ancestor manifest', async () => {
-    const files = fixture()
-    files.writeJson('consumer/package.json', {
-      name: 'self-package',
-      dependencies: { dependency: '*' },
-      optionalDependencies: { optional: '*' },
-      peerDependencies: { peer: '*' },
-    })
-    for (const name of ['self-package', 'dependency', 'optional', 'peer', 'undeclared']) {
-      files.write(`targets/${name}.ts`)
-    }
-    const resolver = files.createResolver(Object.fromEntries(
-      ['self-package', 'dependency', 'optional', 'peer', 'undeclared']
-        .map(name => [name, [`./targets/${name}`]]),
-    ))
-
-    for (const name of ['self-package', 'dependency', 'optional', 'peer']) {
-      await expect(resolver.resolve(name, files.parentURL()))
-        .resolves.toBe(pathToFileURL(files.path(`targets/${name}.ts`)).href)
-    }
-    await expect(resolver.resolve('undeclared', files.parentURL())).resolves.toBeUndefined()
-  })
-
-  it('probes native TypeScript extensions and index files but excludes TSX and missing targets', async () => {
-    const files = fixture()
-    const names = ['plain-ts', 'module-mts', 'common-cts', 'directory', 'tsx-implicit', 'tsx-explicit', 'missing']
-    files.writeJson('consumer/package.json', {
-      dependencies: Object.fromEntries(names.map(name => [name, '*'])),
-    })
-    files.write('targets/plain.ts')
-    files.write('targets/module.mts')
-    files.write('targets/common.cts')
-    files.write('targets/directory/index.ts')
-    files.write('targets/component.tsx')
-    const resolver = files.createResolver({
-      'plain-ts': ['./targets/plain'],
-      'module-mts': ['./targets/module'],
-      'common-cts': ['./targets/common'],
-      'directory': ['./targets/directory'],
-      'tsx-implicit': ['./targets/component'],
-      'tsx-explicit': ['./targets/component.tsx'],
-      'missing': ['./targets/missing'],
-    })
-
-    for (const [name, target] of [
-      ['plain-ts', 'targets/plain.ts'],
-      ['module-mts', 'targets/module.mts'],
-      ['common-cts', 'targets/common.cts'],
-      ['directory', 'targets/directory/index.ts'],
-    ] as const) {
-      await expect(resolver.resolve(name, files.parentURL()))
-        .resolves.toBe(pathToFileURL(files.path(target)).href)
-    }
-    await expect(resolver.resolve('tsx-implicit', files.parentURL())).resolves.toBeUndefined()
-    await expect(resolver.resolve('tsx-explicit', files.parentURL())).resolves.toBeUndefined()
-    await expect(resolver.resolve('missing', files.parentURL())).resolves.toBeUndefined()
-  })
-
-  it('anchors inherited paths at the config that declared them', async () => {
-    const files = fixture()
-    files.writeJson('consumer/package.json', { dependencies: { custom: '*' } })
-    files.write('targets/custom.ts')
-    files.writeJson('base.json', { compilerOptions: { paths: { custom: ['./targets/custom'] } } })
-    const customTsconfig = files.writeJson('configs/custom.json', { extends: '../base.json' })
-    const resolver = TsconfigPathsResolver.create(customTsconfig)
-
-    await expect(resolver.resolve('custom', files.parentURL()))
-      .resolves.toBe(pathToFileURL(files.path('targets/custom.ts')).href)
-  })
-
-  it('short-circuits matched aliases and delegates unsupported schemes or unmatched requests', async () => {
-    const files = fixture()
-    files.writeJson('consumer/package.json', { dependencies: { matched: '*' } })
-    const target = files.write('targets/matched.ts')
-    const tsconfigPath = files.writeJson('tsconfig.json', {
-      compilerOptions: { paths: { matched: ['./targets/matched'] } },
-    })
-    initialize({ tsconfigPath })
-    const context: ResolveHookContext = {
-      conditions: [],
-      importAttributes: {},
-      parentURL: files.parentURL(),
-    }
-    const nextResolve = vi.fn(async (
-      specifier: string,
-      _context: ResolveHookContext,
-    ): Promise<ResolveFnOutput> => ({ url: `next:${specifier}` }))
-
-    await expect(resolveHook('matched', context, nextResolve))
-      .resolves.toEqual({ url: pathToFileURL(target).href, shortCircuit: true })
-    expect(nextResolve).not.toHaveBeenCalled()
-
-    for (const specifier of ['unmatched', 'node:fs', 'data:text/javascript,export default 1', 'https://example.test/mod.ts']) {
-      await expect(resolveHook(specifier, context, nextResolve)).resolves.toEqual({ url: `next:${specifier}` })
-      expect(nextResolve).toHaveBeenLastCalledWith(specifier, context)
-    }
-  })
-})

+ 10 - 7
bin/dsh

@@ -1,7 +1,7 @@
 #!/bin/sh
-# dsh launcher: runs the apps/cli `dsh` bin FROM SOURCE through Node's native
-# TypeScript transform, so a symlink from anywhere (e.g. ~/.local/bin/dsh)
-# always executes the current working tree without a build step.
+# dsh launcher: runs the apps/cli `dsh` bin FROM SOURCE through the tsx ESM
+# hook, so a symlink from anywhere (e.g. ~/.local/bin/dsh) always executes the
+# current working tree without a build step.
 set -eu
 
 # Resolve symlink chains without readlink -f (not on every macOS).
@@ -15,8 +15,11 @@ while [ -L "$script" ]; do
 done
 root=$(CDPATH='' cd -- "$(dirname -- "$script")/.." && pwd)
 
-# The preloader projects this checkout's tsconfig paths into Node resolution;
-# TypeScript transformation itself remains Node-owned (no tsx/esbuild hook).
-exec node --experimental-transform-types \
-  --import "$root/scripts/tspath-loader.ts" \
+# The ESM-only tsx hook transforms TypeScript and projects this checkout's
+# tsconfig paths into Node resolution (the CJS hook stays off: the graph is
+# ESM-only and the CJS resolver costs ~0.4s of startup). Absolute paths keep
+# both the hook and the tsconfig anchored to this checkout when the launcher
+# runs from any cwd, where bare `tsx/esm` would not resolve.
+TSX_TSCONFIG_PATH="$root/tsconfig.json" \
+  exec node --import "$root/node_modules/tsx/dist/esm/index.mjs" \
   "$root/apps/cli/src/bin.ts" "$@"

+ 3 - 3
package.json

@@ -96,13 +96,13 @@
     "constraints": "tsx scripts/check-workspace-constraints.ts",
     "doc-sync": "tsx scripts/run-gates.ts doc-sync",
     "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure",
-    "dsh": "node --experimental-transform-types --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts",
+    "dsh": "node --import tsx/esm apps/cli/src/bin.ts",
     "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml",
-    "demo:tui": "node --experimental-transform-types --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts",
+    "demo:tui": "node --import tsx/esm apps/cli/src/bin.ts",
     "demo:code-mode": "node scripts/demo-code-mode.mjs",
     "demo:cordis": "node scripts/demo-cordis.mjs",
     "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml",
-    "demo:web": "npm run build && node --experimental-transform-types --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts web",
+    "demo:web": "npm run build && node --import tsx/esm apps/cli/src/bin.ts web",
     "mock:llm": "node --import tsx packages/support/llm-mock-server/src/bin.ts",
     "dev:web": "tsx scripts/dev-web.ts --poll",
     "postinstall": "node scripts/install-lefthook.mjs"

+ 1 - 2
scripts/demo-code-mode.mjs

@@ -8,9 +8,8 @@ import { spawn } from 'node:child_process'
 // Each UI's node invocation matches its base demo script plus the overlay config.
 const UIS = new Map([
   ['tui', [
-    '--experimental-transform-types',
     '--import',
-    './scripts/tspath-loader.ts',
+    'tsx/esm',
     'apps/cli/src/bin.ts',
     '--config',
     'examples/tui-agent/code-mode.cordis.yml',

+ 5 - 0
scripts/run-gates.ts

@@ -287,6 +287,11 @@ function nodeCompatSmokeGates(): Gate[] {
       'run',
       'packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts',
     ], { label: 'JSONL Zstandard smoke' }),
+    pnpmExec('dsh-source-launch-smoke', [
+      'vitest',
+      'run',
+      'apps/cli/tests/source-launch.compat.spec.ts',
+    ], { label: 'dsh source-launch smoke' }),
   ]
 }
 

+ 0 - 14
scripts/tspath-loader.ts

@@ -1,14 +0,0 @@
-/** Register source-only tsconfig paths resolution before a TypeScript entry loads. */
-
-import { register } from 'node:module'
-import { resolve } from 'node:path'
-import { fileURLToPath } from 'node:url'
-
-const tsconfigPath = process.env.TSX_TSCONFIG_PATH === undefined
-  ? fileURLToPath(new URL('../tsconfig.json', import.meta.url))
-  : resolve(process.env.TSX_TSCONFIG_PATH)
-
-register(new URL('../apps/cli/src/tsconfig-paths-loader.ts', import.meta.url), {
-  parentURL: import.meta.url,
-  data: { tsconfigPath },
-})