Reorganize packages into a modular hierarchy

Move the 18 flat packages/<name> packages into role-grouped dirs:
core/, llm/, bash/, session-persistence/, ui/, support/. Group dirs are
pure containers; each package keeps its @deepseek-ai/dsh-* name.

Collapse the per-package tsconfig paths maps (base + typecheck) into one
@deepseek-ai/dsh-* wildcard with a candidate per group, and derive the
publint list from the hierarchy. Update all depth-coupled globs/configs
(workspace, tsdown, vitest, eslint, knip, tsconfig includes/refs,
per-package tsconfigs, generators, doc-script scopes, type-equiv manifest)
and the cross-package/script relative imports in tests.

Fix doc-typecheck's workspacePaths() to parse tsconfig JSONC via the
TypeScript API instead of a regex comment-strip, which corrupted the
new wildcard `/*/` path candidates.

WIP: doc cross-links and package/RFC docs still to update.
This commit is contained in:
Tianyi Cui
2026-06-20 22:55:20 +08:00
parent 906705e353
commit d02e9f1bd6
191 changed files with 822 additions and 624 deletions

View File

@@ -0,0 +1,31 @@
# @deepseek-ai/dsh-bash
The **bash executor seam**: an abstract `BashExecutor` service (`ctx.bash`) defining WHAT a bash backend does — run commands, manage background tasks — without saying HOW.
This package is one third of the bash capability, split so each concern can evolve (and be swapped) independently:
| Package | Role |
|---|---|
| `@deepseek-ai/dsh-bash` (this) | the interface: abstract service + vocabulary types |
| `@deepseek-ai/dsh-bash-local` | an implementation: local subprocesses |
| `@deepseek-ai/dsh-tool-bash` | the model-facing tool schemas over `ctx.bash` |
The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool survey: pi hides execution behind a `BashOperations` interface (local shell / SSH / VM backends), Codex behind an exec-server protocol. A future sandboxed, containerized, or remote executor implements this interface and the tool schemas don't change.
## Service API (`ctx.bash`)
| Member | Semantics |
|---|---|
| `run(spec)` | Foreground execution. Resolves when the command finishes. **Rejects only for infrastructure failures** (unusable workdir, missing shell, pre-aborted signal); nonzero exits, timeout kills, and abort kills resolve with a descriptive `BashRunResult`. |
| `start(spec)` | Background execution. Returns a `BashTask` handle immediately; **no timeout applies** (stop tasks via `kill`). |
| `get(id)` / `list()` | Task lookup. |
| `ownerOf(id)` | The opaque OWNER token recorded for a background task at `start` (from the spec's `owner`), or `undefined` for an unknown id OR a known-but-ownerless task. The executor stores/returns it verbatim and NEVER interprets it — the access POLICY lives in the consumer (`dsh-tool-bash`), which compares `ownerOf(id)` to the caller's token. Storing ownership here (disposed with the executor's fiber) is what makes it survive a consumer HMR reload. |
| `readOutput(id)` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. Throws for unknown ids. |
| `kill(id)` | Kill a running task. Returns `false` when it already finished; throws for unknown ids. |
| `onTaskDone(listener)` | Completion listener (effect-based, disposer returned). Fires exactly once per task; never after the service is disposed. |
Implementations subclass `BashExecutor`, implement the abstract methods, and call `notifyTaskDone(task)` on background completion. Disposal must kill every running task (no orphan processes) — see the HMR-safety tests.
## Vocabulary
`BashExecRequest` (command, workdir?, timeoutMs?, signal?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`string | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts.

View File

@@ -0,0 +1,28 @@
{
"name": "@deepseek-ai/dsh-bash",
"description": "Abstract bash executor seam (ctx.bash) for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": {
"types": "./lib/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,146 @@
/**
* The bash executor seam (`ctx.bash`): an abstract service defining WHAT a
* bash backend does — run commands, manage background tasks — without saying
* HOW. Implementations subclass {@link BashExecutor} and register themselves
* as the `bash` service; `@deepseek-ai/dsh-bash-local` (local subprocesses)
* is the first. Future implementations swap in sandboxes, containers, or
* remote exec servers without touching the tool schemas that consume them
* (`@deepseek-ai/dsh-tool-bash`).
*
* The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the
* surveyed agents: pi hides execution behind a `BashOperations` interface
* (local shell / SSH / VM backends), Codex behind an exec-server protocol.
*
* @module @deepseek-ai/dsh-bash
*/
import { Context, Service } from 'cordis'
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskListener, BashTaskRead } from './types.ts'
export type {
BashExecRequest,
BashExecSpec,
BashRunResult,
BashTask,
BashTaskListener,
BashTaskRead,
BashTaskStatus,
CollectedOutput,
} from './types.ts'
declare module 'cordis' {
interface Context {
bash: BashExecutor
}
}
/**
* Abstract bash execution service. Subclass, implement the abstract methods,
* and load the subclass as a plugin — it registers as `ctx.bash` (one
* implementation per context; loading a second throws, which is cordis'
* standard duplicate-service behavior).
*
* Semantics every implementation must honor:
* - {@link run} REJECTS only for infrastructure failures (unusable workdir,
* missing shell, pre-aborted signal). Nonzero exits, timeout kills, and
* abort kills RESOLVE with a descriptive {@link BashRunResult} — reporting
* a failed command is the tool layer's job, not an exception.
* - {@link start} returns immediately; no timeout applies to background
* tasks (callers stop them via {@link kill} or the spec's AbortSignal).
* Completion must fire the {@link onTaskDone} listeners exactly once per
* task, and must NOT fire after the service is disposed.
* - {@link readOutput} is incremental: consecutive reads never re-deliver
* output. Implementations bound their buffers; reads that lost data flag
* `lossy` and point at full-stream spill files when available.
* - Disposal kills every running task and awaits their exit (no orphan
* processes survive `fiber.dispose()`).
*/
export abstract class BashExecutor extends Service {
private listeners = new Set<BashTaskListener>()
private listenersClosed = false
constructor(ctx: Context) {
super(ctx, 'bash')
ctx.effect(() => () => {
// Close the listener registry before subclass teardown so late task
// completions (e.g. from kills issued during dispose) stay silent.
this.listenersClosed = true
this.listeners.clear()
}, 'bash listener teardown')
}
/**
* Resolve a caller's {@link BashExecRequest} into a fully-specified
* {@link BashExecSpec}, applying this implementation's config defaults and
* caps (working directory, default/max timeout). Consumers (tool layer)
* call this, then pass the result to {@link run}/{@link start} — keeping
* defaulting in the implementation that owns the config while the seam type
* stays explicit (no hidden `?? default` inside run/start).
*/
abstract resolve(request: BashExecRequest): BashExecSpec
/** Run a command in the foreground; resolves when it finishes. */
abstract run(spec: BashExecSpec): Promise<BashRunResult>
/** Start a background task and return its handle immediately. */
abstract start(spec: BashExecSpec): BashTask
/** Look up a background task by id. */
abstract get(id: string): BashTask | undefined
/**
* The opaque OWNER token recorded for a background task at {@link start}
* (from the {@link BashExecSpec}'s `owner`), or `undefined` for an unknown id
* OR a known-but-ownerless task. The executor stores and returns the token
* verbatim — it never interprets it; the access POLICY (who may read/kill a
* task) lives in the consumer (`@deepseek-ai/dsh-tool-bash`), which compares
* `ownerOf(id)` to the caller's token. Collapsing unknown-id and
* known-but-unowned into the same `undefined` is fine: the consumer's access
* gate treats `undefined` as "open", and a genuinely unknown id then fails
* loudly at the subsequent {@link readOutput}/{@link kill} ("unknown task").
* Storing ownership in the executor (disposed with ITS fiber) — not in the
* tool plugin — is what makes ownership survive a `tool-bash` HMR reload.
*/
abstract ownerOf(id: string): string | undefined
/** All tracked background tasks (insertion order). */
abstract list(): BashTask[]
/** Read output produced since the previous read. Throws for unknown ids. */
abstract readOutput(id: string): BashTaskRead
/**
* Kill a running background task. Returns false when it had already
* finished (no-op). Throws for unknown ids.
*/
abstract kill(id: string): boolean
/**
* Register a background-task completion listener (disposed with the
* calling fiber). Listeners never fire after this service is disposed.
*/
onTaskDone(listener: BashTaskListener): () => void {
const dispose = this.ctx.effect(() => {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}, 'bash.onTaskDone()')
return () => void dispose()
}
/** For implementations: notify listeners that `task` completed. Listener
* exceptions are contained (logged) — one bad listener must not reject
* `BashTask.done` or starve the listeners after it. */
protected notifyTaskDone(task: BashTask): void {
if (this.listenersClosed) return
for (const listener of this.listeners) {
try {
listener(task)
} catch (error: unknown) {
// Listener bugs are reported, never propagated into task.done.
console.error('bash onTaskDone listener threw:', error)
}
}
}
}
export default BashExecutor

View File

@@ -0,0 +1,115 @@
/**
* Execution vocabulary for the bash executor seam. Types only — the abstract
* service lives in `./index.ts`, implementations in sibling packages
* (`@deepseek-ai/dsh-bash-local` first).
*
* @module dsh-bash/types
*/
/**
* A caller's execution REQUEST: `workdir` and `timeoutMs` are optional and
* filled by {@link BashExecutor.resolve} from the implementation's config.
* This is the model-/plugin-facing shape; pass it to `resolve()` to obtain a
* fully-resolved {@link BashExecSpec}.
*/
export interface BashExecRequest {
command: string
/** Working directory override (default: implementation-configured). */
workdir?: string | undefined
/** Timeout override in milliseconds (implementations cap it). */
timeoutMs?: number | undefined
/** Abort signal — implementations kill the command when it fires. */
signal?: AbortSignal | undefined
/**
* Opaque OWNER token for a background task — the consumer's isolation key
* (the tool layer passes the owning agent's `session.header.id`). The
* executor stores it on the task and exposes it via {@link BashExecutor.ownerOf};
* the executor itself NEVER interprets it (no access policy lives in the
* seam — that is the consumer's job). Absent for foreground runs and for an
* ownerless background start (a non-agent caller).
*/
owner?: string | undefined
}
/**
* A fully-resolved execution SPEC — exactly what {@link BashExecutor.run} /
* {@link BashExecutor.start} act on. `workdir` and `timeoutMs` are REQUIRED:
* defaulting and capping already happened in {@link BashExecutor.resolve}, so
* the executor never hides a `?? config` fallback (explicit > implicit). For
* background tasks, `start()` ignores `timeoutMs` (background runs have no
* timeout) — the field is still required because the type is shared.
*/
export interface BashExecSpec {
command: string
workdir: string
timeoutMs: number
/** Abort signal — implementations kill the command when it fires. */
signal?: AbortSignal | undefined
/**
* Opaque owner token, REQUIRED-but-nullable (mirrors `workdir`/`timeoutMs`
* being required on the resolved spec): {@link BashExecutor.resolve} carries
* the request's `owner` through, defaulting a missing one to `undefined`. A
* required field makes a forgotten owner a VISIBLE `undefined` rather than a
* silently-absent property that yields an unowned (cross-session-readable)
* task. `start()` stores it; `run()` (foreground) ignores it.
*/
owner: string | undefined
}
/** One captured stream: the (possibly truncated) text plus recovery info. */
export interface CollectedOutput {
/** Collected text — the TAIL of the stream when truncated. */
text: string
/** True when bytes were dropped from `text`. */
truncated: boolean
/** Path to a file holding the COMPLETE stream, when truncated and available. */
spillPath?: string
}
/** The outcome of one completed (or killed) foreground run. */
export interface BashRunResult {
/** Exit code; null when the process died from a signal. */
exitCode: number | null
/** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */
signal: NodeJS.Signals | null
/** True when the executor's own timeout killed the command. */
timedOut: boolean
/** True when the caller's AbortSignal killed the command. */
aborted: boolean
/** The effective timeout applied to this run (after defaulting/capping). */
timeoutMs: number
stdout: CollectedOutput
stderr: CollectedOutput
}
/** Lifecycle of a background task. */
export type BashTaskStatus = 'running' | 'completed' | 'killed'
/** A tracked background task handle. */
export interface BashTask {
readonly id: string
readonly command: string
status: BashTaskStatus
/** Exit code once finished (null = killed by signal / still running). */
exitCode: number | null
/** Terminating signal name, when signal-killed. */
signal: NodeJS.Signals | null
/** Resolves when the underlying process closes (never rejects). */
readonly done: Promise<void>
}
/** One incremental {@link BashExecutor.readOutput} read. */
export interface BashTaskRead {
task: BashTask
/** Output produced since the previous read (stderr in a marked section). */
delta: string
/** True when truncation dropped unread bytes the delta cannot include. */
lossy: boolean
/** Full stdout spill file, when stdout truncation occurred and a safe path is available. */
stdoutSpillPath?: string
/** Full stderr spill file, when stderr truncation occurred and a safe path is available. */
stderrSpillPath?: string
}
/** Completion callback for background tasks. */
export type BashTaskListener = (task: BashTask) => void

View File

@@ -0,0 +1,145 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead } from '@deepseek-ai/dsh-bash'
/** Minimal concrete executor: records calls, lets tests drive completions. */
class StubExecutor extends BashExecutor {
tasks = new Map<string, BashTask>()
private owners = new Map<string, string | undefined>()
resolve(request: BashExecRequest): BashExecSpec {
return {
command: request.command,
workdir: request.workdir ?? '/stub',
timeoutMs: request.timeoutMs ?? 1000,
...request.signal ? { signal: request.signal } : {},
owner: request.owner,
}
}
async run(_spec: BashExecSpec): Promise<BashRunResult> {
return {
exitCode: 0,
signal: null,
timedOut: false,
aborted: false,
timeoutMs: 1000,
stdout: { text: 'ok', truncated: false },
stderr: { text: '', truncated: false },
}
}
start(spec: BashExecSpec): BashTask {
const task: BashTask = {
id: `stub-${this.tasks.size + 1}`,
command: spec.command,
status: 'running',
exitCode: null,
signal: null,
done: Promise.resolve(),
}
this.tasks.set(task.id, task)
this.owners.set(task.id, spec.owner)
return task
}
get(id: string): BashTask | undefined {
return this.tasks.get(id)
}
ownerOf(id: string): string | undefined {
return this.owners.get(id)
}
list(): BashTask[] {
return [...this.tasks.values()]
}
readOutput(id: string): BashTaskRead {
const task = this.tasks.get(id)
if (!task) throw new Error(`unknown bash task "${id}"`)
return { task, delta: '', lossy: false }
}
kill(id: string): boolean {
const task = this.tasks.get(id)
if (!task) throw new Error(`unknown bash task "${id}"`)
if (task.status !== 'running') return false
task.status = 'killed'
return true
}
/** Expose the protected notifier for tests. */
fire(task: BashTask): void {
this.notifyTaskDone(task)
}
}
async function setup() {
const ctx = new Context()
await ctx.plugin(StubExecutor)
// ctx.bash resolves to the registered implementation.
const bash = ctx.bash as StubExecutor
return { ctx, bash }
}
describe('BashExecutor service seam', () => {
it('registers as ctx.bash and serves the abstract API', async () => {
const { bash } = await setup()
const task = bash.start(bash.resolve({ command: 'sleep 1' }))
expect(bash.get(task.id)).toBe(task)
expect(bash.list()).toEqual([task])
expect(bash.kill(task.id)).toBe(true)
expect(bash.kill(task.id)).toBe(false)
const result = await bash.run(bash.resolve({ command: 'true' }))
expect(result.exitCode).toBe(0)
})
it('onTaskDone delivers completions to registered listeners', async () => {
const { bash } = await setup()
const seen: string[] = []
bash.onTaskDone(task => void seen.push(task.id))
const task = bash.start(bash.resolve({ command: 'x' }))
bash.fire(task)
expect(seen).toEqual([task.id])
})
it('onTaskDone disposer unsubscribes the listener', async () => {
const { bash } = await setup()
const listener = vi.fn()
const dispose = bash.onTaskDone(listener)
dispose()
bash.fire(bash.start(bash.resolve({ command: 'x' })))
expect(listener).not.toHaveBeenCalled()
})
it('listeners registered from a fiber are removed on dispose (HMR safety)', async () => {
const { ctx, bash } = await setup()
const listener = vi.fn()
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.bash.onTaskDone(listener)
}, { inject: ['bash'] }))
bash.fire(bash.start(bash.resolve({ command: 'one' })))
expect(listener).toHaveBeenCalledTimes(1)
await fiber.dispose()
bash.fire(bash.start(bash.resolve({ command: 'two' })))
expect(listener).toHaveBeenCalledTimes(1)
})
it('silences listeners once the service fiber is disposed', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(Object.assign(async (inner: Context) => {
await inner.plugin(StubExecutor)
}, {}))
const bash = ctx.bash as StubExecutor
const listener = vi.fn()
bash.onTaskDone(listener)
const task = bash.start(bash.resolve({ command: 'x' }))
await fiber.dispose()
bash.fire(task)
expect(listener).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,18 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
}
]
}