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:
191 files changed
+822
-624
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user