index.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. /**
  2. * The bash executor seam (`ctx.bash`): an abstract service defining WHAT a
  3. * bash backend does — run commands, manage background tasks — without saying
  4. * HOW. Implementations subclass {@link BashExecutor} and register themselves
  5. * as the `bash` service; `@deepseek-ai/dsh-bash-local` (local subprocesses)
  6. * is the first. Future implementations swap in sandboxes, containers, or
  7. * remote exec servers without touching the tool schemas that consume them
  8. * (`@deepseek-ai/dsh-tool-bash`).
  9. *
  10. * The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the
  11. * surveyed agents: pi hides execution behind a `BashOperations` interface
  12. * (local shell / SSH / VM backends), Codex behind an exec-server protocol.
  13. *
  14. * @module @deepseek-ai/dsh-bash
  15. */
  16. import { Context, Service } from 'cordis'
  17. import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskListener, BashTaskRead, OwnerToken } from './types.ts'
  18. export { BashTaskId, OwnerToken } from './types.ts'
  19. export type {
  20. BashExecRequest,
  21. BashExecSpec,
  22. BashRunResult,
  23. BashTask,
  24. BashTaskListener,
  25. BashTaskRead,
  26. BashTaskStatus,
  27. CollectedOutput,
  28. } from './types.ts'
  29. declare module 'cordis' {
  30. interface Context {
  31. bash: BashExecutor
  32. }
  33. }
  34. /**
  35. * Abstract bash execution service. Subclass, implement the abstract methods,
  36. * and load the subclass as a plugin — it registers as `ctx.bash` (one
  37. * implementation per context; loading a second throws, which is cordis'
  38. * standard duplicate-service behavior).
  39. *
  40. * Semantics every implementation must honor:
  41. * - {@link run} REJECTS only for infrastructure failures (unusable workdir,
  42. * missing shell, pre-aborted signal). Nonzero exits, timeout kills, and
  43. * abort kills RESOLVE with a descriptive {@link BashRunResult} — reporting
  44. * a failed command is the tool layer's job, not an exception.
  45. * - {@link start} returns immediately; no timeout applies to background
  46. * tasks (callers stop them via {@link kill} or the spec's AbortSignal).
  47. * Completion must fire the {@link onTaskDone} listeners exactly once per
  48. * task, and must NOT fire after the service is disposed.
  49. * - {@link readOutput} is incremental: consecutive reads never re-deliver
  50. * output. Implementations bound their buffers; reads that lost data flag
  51. * `lossy` and point at full-stream spill files when available.
  52. * - Disposal kills every running task and awaits their exit (no orphan
  53. * processes survive `fiber.dispose()`).
  54. */
  55. export abstract class BashExecutor extends Service {
  56. private listeners = new Set<BashTaskListener>()
  57. private listenersClosed = false
  58. constructor(ctx: Context) {
  59. super(ctx, 'bash')
  60. ctx.effect(() => () => {
  61. // Close the listener registry before subclass teardown so late task
  62. // completions (e.g. from kills issued during dispose) stay silent.
  63. this.listenersClosed = true
  64. this.listeners.clear()
  65. }, 'bash listener teardown')
  66. }
  67. /**
  68. * Resolve a caller's {@link BashExecRequest} into a fully-specified
  69. * {@link BashExecSpec}, applying this implementation's config defaults and
  70. * caps (working directory, default/max timeout). Consumers (tool layer)
  71. * call this, then pass the result to {@link run}/{@link start} — keeping
  72. * defaulting in the implementation that owns the config while the seam type
  73. * stays explicit (no hidden `?? default` inside run/start).
  74. */
  75. abstract resolve(request: BashExecRequest): BashExecSpec
  76. /** Run a command in the foreground; resolves when it finishes. */
  77. abstract run(spec: BashExecSpec): Promise<BashRunResult>
  78. /** Start a background task and return its handle immediately. */
  79. abstract start(spec: BashExecSpec): BashTask
  80. /** Look up a background task by id. */
  81. abstract get(id: BashTaskId): BashTask | undefined
  82. /**
  83. * The opaque OWNER token recorded for a background task at {@link start}
  84. * (from the {@link BashExecSpec}'s `owner`), or `undefined` for an unknown id
  85. * OR a known-but-ownerless task. The executor stores and returns the token
  86. * verbatim — it never interprets it; the access POLICY (who may read/kill a
  87. * task) lives in the consumer (`@deepseek-ai/dsh-tool-bash`), which compares
  88. * `ownerOf(id)` to the caller's token. Collapsing unknown-id and
  89. * known-but-unowned into the same `undefined` is fine: the consumer's access
  90. * gate treats `undefined` as "open", and a genuinely unknown id then fails
  91. * loudly at the subsequent {@link readOutput}/{@link kill} ("unknown task").
  92. * Storing ownership in the executor (disposed with ITS fiber) — not in the
  93. * tool plugin — is what makes ownership survive a `tool-bash` HMR reload.
  94. */
  95. abstract ownerOf(id: BashTaskId): OwnerToken | undefined
  96. /** All tracked background tasks (insertion order). */
  97. abstract list(): BashTask[]
  98. /** Read output produced since the previous read. Throws for unknown ids. */
  99. abstract readOutput(id: BashTaskId): BashTaskRead
  100. /**
  101. * Kill a running background task. Returns false when it had already
  102. * finished (no-op). Throws for unknown ids.
  103. */
  104. abstract kill(id: BashTaskId): boolean
  105. /**
  106. * Register a background-task completion listener (disposed with the
  107. * calling fiber). Listeners never fire after this service is disposed.
  108. */
  109. onTaskDone(listener: BashTaskListener): () => void {
  110. const dispose = this.ctx.effect(() => {
  111. this.listeners.add(listener)
  112. return () => this.listeners.delete(listener)
  113. }, 'bash.onTaskDone()')
  114. return () => void dispose()
  115. }
  116. /** For implementations: notify listeners that `task` completed. Listener
  117. * exceptions are contained (logged) — one bad listener must not reject
  118. * `BashTask.done` or starve the listeners after it. */
  119. protected notifyTaskDone(task: BashTask): void {
  120. if (this.listenersClosed) return
  121. for (const listener of this.listeners) {
  122. try {
  123. listener(task)
  124. } catch (error: unknown) {
  125. // Listener bugs are reported, never propagated into task.done.
  126. console.error('bash onTaskDone listener threw:', error)
  127. }
  128. }
  129. }
  130. }
  131. export default BashExecutor