config.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. /** Validated configuration for the local PTY backend. */
  2. import z from '@deepseek-ai/schemastery'
  3. import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
  4. /** One supported interactive shell dialect. */
  5. export type ShellDialect = 'bash' | 'pwsh'
  6. /** Public plugin configuration. */
  7. export interface Config {
  8. /** Backend registry type (default: `shell`). */
  9. backendType?: string
  10. /** Interactive shell dialect (default: `bash`); selects the argv/env/startup defaults. */
  11. shellDialect?: ShellDialect
  12. /** Interactive shell executable (default per dialect: `/bin/bash`, or the resolved pwsh). */
  13. shellPath?: string
  14. /** Shell arguments (default per dialect: bash `--noprofile --norc -i`, pwsh `-NoLogo -NoProfile`). */
  15. shellArgs?: string[]
  16. /** Terminal rows. */
  17. rows?: number
  18. /** Terminal columns. */
  19. cols?: number
  20. /** Maximum retained logical lines. */
  21. scrollbackLines?: number
  22. /** Maximum retained UTF-8 bytes. */
  23. scrollbackMaxBytes?: number
  24. /** Maximum bytes returned by one read or settled viewport. */
  25. maxReadBytes?: number
  26. /** Readiness polling interval. */
  27. pollIntervalMs?: number
  28. /** Delay before Linux exact syscall probes. */
  29. exactProbeAfterMs?: number
  30. /** Silence duration that yields `inferred_idle`. */
  31. idleSilenceMs?: number
  32. /**
  33. * Extra wait beyond `idleSilenceMs`, once a prompt marker was seen, for the shell to
  34. * regain the foreground before `inferred_idle` settles; at least one `pollIntervalMs`.
  35. */
  36. handoffGraceMs?: number
  37. /** Absolute bound for one send and the complete pwsh startup sequence. */
  38. timeoutMs?: number
  39. /** Grace before teardown escalates to `SIGKILL`. */
  40. disposeGraceMs?: number
  41. }
  42. /** Configuration after Schemastery defaults and dialect resolution. */
  43. export type ResolvedConfig = Omit<Required<Config>, 'shellDialect' | 'shellPath' | 'shellArgs'> & {
  44. shellDialect: ShellDialect
  45. shellPath: string
  46. shellArgs: string[]
  47. }
  48. /** Bash dialect default executable. */
  49. export const DEFAULT_BASH_SHELL = '/bin/bash'
  50. /** Bash dialect default arguments (interactive, profile-free). */
  51. export const DEFAULT_BASH_ARGS = ['--noprofile', '--norc', '-i']
  52. /** Pwsh dialect default arguments (interactive host, profile-free). */
  53. export const DEFAULT_PWSH_ARGS = ['-NoLogo', '-NoProfile']
  54. /**
  55. * Resolve the effective per-dialect shell specification. Defaulting is this
  56. * explicit step: an unset or empty `shellPath`/`shellArgs` selects the
  57. * dialect's defaults, while a non-empty explicit value always wins.
  58. * (Schemastery materializes an absent optional array as `[]`, so emptiness —
  59. * not just `undefined` — means "dialect default".)
  60. * @param config - Schemastery-resolved plugin configuration.
  61. * @returns the fully resolved configuration.
  62. */
  63. export function resolveConfig(config: Config): ResolvedConfig {
  64. const shellDialect = config.shellDialect ?? 'bash'
  65. return {
  66. ...(config as Required<Config>),
  67. shellDialect,
  68. shellPath: config.shellPath !== undefined && config.shellPath.length > 0
  69. ? config.shellPath
  70. : (shellDialect === 'pwsh' ? resolvePwshPath() : DEFAULT_BASH_SHELL),
  71. shellArgs: config.shellArgs !== undefined && config.shellArgs.length > 0
  72. ? config.shellArgs
  73. : (shellDialect === 'pwsh' ? DEFAULT_PWSH_ARGS : DEFAULT_BASH_ARGS),
  74. }
  75. }
  76. /** Schemastery config exposed by the plugin. */
  77. export const Config: z<Config> = z.object({
  78. backendType: z.string().default('shell'),
  79. shellDialect: z.union(['bash', 'pwsh'] as const).default('bash'),
  80. shellPath: z.string().required(false),
  81. shellArgs: z.array(z.string()).required(false),
  82. rows: z.number().default(40),
  83. cols: z.number().default(160),
  84. scrollbackLines: z.number().default(10_000),
  85. scrollbackMaxBytes: z.number().default(4 * 1024 * 1024),
  86. maxReadBytes: z.number().default(256 * 1024),
  87. pollIntervalMs: z.number().default(50),
  88. exactProbeAfterMs: z.number().default(150),
  89. idleSilenceMs: z.number().default(3_000),
  90. handoffGraceMs: z.number().default(500),
  91. timeoutMs: z.number().default(30_000),
  92. disposeGraceMs: z.number().default(3_000),
  93. })
  94. /**
  95. * Assert every effective numeric config field is a positive safe integer and bounds compose.
  96. * @param config - Schemastery-resolved plugin configuration.
  97. * @returns Narrows the input to the fully resolved configuration.
  98. */
  99. export function validateConfig(config: Config): asserts config is ResolvedConfig {
  100. const resolved = config as ResolvedConfig
  101. if (resolved.backendType.length === 0) throw new Error('terminal-bash: backendType must be non-empty')
  102. if (resolved.shellPath.length === 0) throw new Error('terminal-bash: shellPath must be non-empty')
  103. for (const [name, value] of Object.entries(resolved)) {
  104. if (typeof value === 'number' && (!Number.isSafeInteger(value) || value <= 0)) {
  105. throw new Error(`terminal-bash: ${name} must be a positive safe integer`)
  106. }
  107. }
  108. if (resolved.maxReadBytes > resolved.scrollbackMaxBytes) {
  109. throw new Error('terminal-bash: maxReadBytes must not exceed scrollbackMaxBytes')
  110. }
  111. if (resolved.handoffGraceMs < resolved.pollIntervalMs) {
  112. throw new Error('terminal-bash: handoffGraceMs must be at least pollIntervalMs so one readiness poll runs inside the grace window')
  113. }
  114. }