diff --git a/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.md b/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.md index 3fa7227d04..05d72e4e49 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.md +++ b/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.md @@ -6,29 +6,28 @@ Status: implemented An example folder is supposed to be *thin* — the variable wiring of a demo, not the demo's machinery. Before this change it was thick. Each example carried a hand-rolled `start.ts` boot bootstrap, an infra preamble (`timer`, and — for the stdio demos — `logger` + `hmr`), nested includes of three shared YAML fragments (`base.yml` / `base-core.yml` / `acp-agent/acp-tail.yml`), and per-example `agent-loop`/persistence/system-prompt config. The actual app — the spine of services every agent needs — was spread across the leaf and those includes. -The leaf configs also owned a coupled front door. ACP requires stdout purity and creates agents through `session/new`; stdio requires a console logger and a pre-created `main`. Prose warnings were the only guard against combining these incorrectly, while three `start.ts` files duplicated the Loader bootstrap and lifecycle code. +The leaf configs also owned coupled front doors. ACP requires stdout purity and creates agents through `session/new`; terminal and Headless apps pre-create `main` but have different process I/O contracts. Prose warnings were the only guard against combining these incorrectly, while three `start.ts` files duplicated the Loader bootstrap and lifecycle code. ## Decision Each example is now **mostly an invocation of an app package**, splitting the wiring along the existing [interface / implementation / consumer seam](2026-06-13-capability-seams.md): the **app package owns the composition**, the leaf `cordis.yml` owns only the **swappable choices** (which LLM adapter, which bash executor, model, prompt, persistence root). - **`@deepseek-ai/dsh-agent-spine-demo`** ([packages/examples/agent-spine-demo](../../../../packages/examples/agent-spine-demo)) composes the providerless, executor-less, UI-less spine and forwards the loop's agent-list config. Its dependency on the concrete loop is intentional because this package composes the spine rather than extending it; swapping the loop means supplying another bundle. -- **`@deepseek-ai/dsh-stdio-demo`** ([packages/examples/stdio-demo](../../../../packages/examples/stdio-demo)) and **`@deepseek-ai/dsh-acp-demo`** ([packages/examples/acp-demo](../../../../packages/examples/acp-demo)) bake in their front doors. Stdio includes `ui-stdio`, a console logger, and `main`; ACP includes the bridge and JSONL persistence but no stdout logger or pre-created agent. Leaves may add plugins, but the safe composition is now the default artifact. -- **`start.ts` is gone.** Each app package exposes a `bin` (`dsh-stdio-demo` / `dsh-acp-demo`); the `demo:*` scripts invoke it (e.g. `dsh-stdio-demo ./cordis.yml`). The Loader-boot tail, `.env` loading, and fail-loud guards live in the shared [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) package (unit-tested under the per-file coverage gate — see [share the app bins' boot glue](../simplification/2026-07-04-share-app-bin-boot-glue.md)); each bin is a thin self-executing composition over those helpers plus its app-specific lifecycle (the ACP bin: snapshot-mode selection and stdin-dispose). The `bin.ts` files themselves stay coverage-excluded (self-executing CLI entries, like the old `start.ts`) and are driven by the keyless Loader-path tests. -- **Each leaf `cordis.yml` collapses** to backends + config: the LLM adapter (`llm-deepseek` with apiKey/models, or `llm-replay`), the bash executor (`bash-local`), `hmr` for the stdio demos (see the amendment below), and one app entry carrying the app's config (model, system prompt, persistence root — surfaced as the app package's own `Config`, which routes each value to wherever the app wires it: stdio onto its pre-created agent, acp onto the bridge plugin). -- **echo-agent folds onto `dsh-stdio-demo`**, swapping the LLM backend to the local `mock-llm` and adding the local `echo-tool` (plus `bash-local`, which the spine's `tool-bash` injects) at the leaf — the clean demonstration of "swap the backend, keep the app". `mock-llm.ts` / `echo-tool.ts` stay as example-local teaching plugins. +- **`@deepseek-ai/dsh-tui-demo`**, **`@deepseek-ai/dsh-cli-demo`**, and **`@deepseek-ai/dsh-acp-demo`** bake in their process roles. TUI includes the full-screen UI and a pre-created `main`; Headless includes the one-shot driver and a pre-created `main`; ACP includes the bridge and no pre-created agent. All three include JSONL persistence and omit stdout loggers. +- **`start.ts` is gone.** Each app package exposes a bin; the `demo:*` scripts invoke it. Loader boot, `.env` loading, and fail-loud guards live in the shared [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) package (unit-tested under the per-file coverage gate — see [share the app bins' boot glue](../simplification/2026-07-04-share-app-bin-boot-glue.md)); the thin self-executing entries are driven by keyless Loader-path tests. +- **Each leaf `cordis.yml` collapses** to backends, optional product tools, and one app entry carrying the app config. TUI and Headless route model/session choices onto a pre-created agent; ACP routes the initial provider/model onto its bridge. - **`base.yml`, `base-core.yml`, and `acp-agent/acp-tail.yml` are retired** — the spine they shared now lives in `dsh-agent-spine-demo`. `bash-local` and the LLM adapter stay **leaf choices**: the bundle ships `tool-bash` (the consumer schema), the leaf picks the executor implementation, so a sandboxed executor or replay adapter swaps in without touching the app. ### Amendment on implementation: `hmr` stays a leaf entry -The proposal listed `hmr` among the stdio app's baked-in front-door cluster. Validating against the code, baking `hmr` into the `dsh-stdio-demo` package fights cordis in two ways, so it ships as a **leaf `cordis.yml` entry** instead: +The proposal listed `hmr` among the interactive app's baked-in front-door cluster. Validating against the code, baking `hmr` into the app package fights Cordis in two ways, so it ships as a **leaf `cordis.yml` entry** instead: 1. `@cordisjs/plugin-hmr` is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader` service, so it can only run in the real `demo:*`/bin subprocess, never in the in-process unit/coverage tier. 2. The in-process test tier (vitest) cannot even *import* the vendored `hmr` module (its class-decorator `@Inject` form fails under Vite's transform), so a package whose `apply` statically imported it could never satisfy the per-file 100% coverage gate on its headline function. -Crucially, `hmr` is **not** a stdout-purity footgun the way the console logger is — a stray `hmr` in the ACP config would not corrupt the JSON-RPC frames — so leaving it at the leaf costs none of the safety the coupling argument is about. The **logger** (the real coupling) stays baked in: the stdio app includes it, the ACP app omits it. +Crucially, `hmr` is not a stdout-purity footgun: a stray entry in the ACP config does not corrupt JSON-RPC frames. Every shipped app omits a stdout console logger; the app or protocol driver alone owns stdout. ## Alternatives considered @@ -39,13 +38,13 @@ The old `base*.yml`/`acp-tail.yml` includes already deduped the *config*, but a ## Verification - Example directories contain only their config, README, and tests: `start.ts`, the infrastructure preamble, and the shared YAML includes are gone. -- `demo:echo`, `demo:repl`, and `demo:acp` invoke the app-package bins. +- `demo:tui`, `demo:headless`, and `demo:acp` invoke the app-package bins. - Each new package has a README and per-file 100% coverage; each app package also has a keyless real-Loader-path bin smoke that catches export-shape failures described in [postmortem 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md). - The ACP replay transcript remains unchanged because the plugin set and load order did not change. ## Consequences -- **The bare-plugin-tree pedagogy.** echo-agent's inlined `cordis.yml` showed every plugin at once; the spine now lives behind a bundle, so seeing the whole tree means opening `dsh-agent-spine-demo`. The app package's README carries that teaching weight. +- **The bare-plugin-tree pedagogy.** The spine lives behind a bundle, so seeing the whole tree means opening `dsh-agent-spine-demo`. The app package's README carries that teaching weight. - **A layer of indirection.** "What does this demo load?" becomes a package read, not a single YAML scan. ## Related @@ -53,3 +52,4 @@ The old `base*.yml`/`acp-tail.yml` includes already deduped the *config*, but a - Supersedes [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md): renaming `base.yml` to the providerless core is moot once the spine moves into `dsh-agent-spine-demo` and the `base*.yml` files are deleted. - Builds on the [capability-seams](2026-06-13-capability-seams.md) interface/implementation/consumer split — backends and presentation stay leaf choices; the spine is the shared bundle. - Complements [Reorganize packages into a modular hierarchy](2026-06-20-package-hierarchy.md): the new app/core packages slot into existing groups under that hierarchy (`core` for the reusable spine bundle, `ui` for the app-specific front doors). +- The later [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) owns the final TUI/Headless split and removes the line-oriented and mock-only leaves. diff --git a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md index d853696226..85c62b7e17 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md +++ b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md @@ -2,6 +2,8 @@ Status: implemented +The later [fold-stdio-helper](../simplification/2026-07-04-fold-stdio-ui-helper.md) decision superseded the original `support/ui-stdio` placement, and the [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) subsequently removed that surface entirely. The uniform depth-two hierarchy remains the decision owned here. + ## Problem `packages/` was flat: 18 packages all sat at `packages//`, so a package's location said nothing about whether it was core product API, a swappable capability seam, a provider adapter, a product integration, or example/test support. The package README carried a `FIXME(package-hierarchy)` and `scripts/publint-all.ts` a `TODO(package-inventory)` flagging exactly this. Core packages, provider integrations, capability seams, example UI support, and snapshot-only replay support all looked equally foundational. diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index 631e5b570c..cdd37091a2 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -8,7 +8,7 @@ The assembled system prompt had four defects, all of one family: facts the harne **The model could not know its own name.** `AgentOptions.model` drives every request, but no prompt text carried it — and nothing COULD carry it: sections in `dsh-system-prompt` were context-global while the model name is per-agent, and `assemble()` took no per-agent input at all. -**Tool guidance was hand-written prose in leaf YAML.** The bash/subagent/todo_write usage guidance lived in the `systemPrompt` strings of `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml` — two drifting copies (the ACP one was already abridged) — while `dsh-tool-fs` and `dsh-tool-web` owned their guidance as `ctx.systemPrompt.section()` contributions. Loading or dropping a tool plugin meant editing every deployment's persona by hand; both YAMLs carried a `FIXME(config-comments)` apologizing for a symptom of the split, and the stdio welcome banner hand-enumerated the tool set too. +**Tool guidance was hand-written prose in leaf YAML.** The bash/subagent/todo_write usage guidance lived in the coding-agent and ACP persona strings — two drifting copies (the ACP one was already abridged) — while `dsh-tool-fs` and `dsh-tool-web` owned their guidance as `ctx.systemPrompt.section()` contributions. Loading or dropping a tool plugin meant editing every deployment's persona by hand; both YAMLs carried a `FIXME(config-comments)` apologizing for a symptom of the split, and the old terminal welcome banner hand-enumerated the tool set too. **The persona rendered after tool guidance.** The loop string-joined `agent.options.systemPrompt` AFTER the assembled sections, so the model read "Use the read tool…" before "You are a coding agent" — backwards relative to the identity-first convention (Claude Code, Codex) and a second composition path besides the section pipeline. @@ -56,7 +56,7 @@ Per-tool semantics and selection guidance live in tool descriptions. Prompt sect ## Shipped invariants -- The repl-agent prompt renders identity, persona with the interpolated model, then fs/bash/web guidance through one assembly path. +- The tui-agent prompt renders identity, persona with the interpolated model, then fs/bash/web guidance through one assembly path. - Fork and fresh subagent descriptions reflect whether the provider inherits completed conversation turns; the tool appears, disappears, and is reworded with provider lifecycle changes. - Unknown, valueless, malformed, or unbalanced variable references name the section and throw; duplicate section, variable, and tool registrations also throw. - Snapshot replay is prompt-independent: it keys recorded chunk streams by turn and step without comparing the outgoing request. diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md index 1255dc7328..a9197de179 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md @@ -162,7 +162,7 @@ Those cases can consume `ctx.spillStore` directly in later work. They are not pa - `dsh-spill-local` unit tests cover `saveText`, `encodeSegment` sanitization (separators/tilde/whole-segment dots/empty), the session-hash directory, owner-only permissions, distinct paths per save, the configured/private root, and a storage-failure rejection. - `dsh-spill-policy` unit tests drive real tools through `ctx.tools.execute`: disabled-mode no-op, oversized-text replacement, small/non-text passthrough, `read` skip, best-effort fallback (save failure / no backend / no owner), and downstream-composition (bounding a replaced result, preserving `additionalContexts`). - `dsh-tool-web` integration drives `web_fetch` through `ctx.tools.execute` with the real `spill-local` backend + policy, proving the model-facing text changes only by the deliberate spill notice while the spill file holds the full formatted result. -- The `repl-agent` example loads `spill-local` + `spill-policy`, so its keyless Loader smoke exercises the real load path (the namespace-plugin export shape + `inject`). +- The `tui-agent` example loads `spill-local` + `spill-policy`, so its keyless Loader/PTY smoke exercises the real load path (the namespace-plugin export shape + `inject`). ## Consequences diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md index beb7de9c9e..40649f39ed 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -116,7 +116,7 @@ Two failure paths, both documented: - **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry. - **`dsh-compact`** owns `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)`, the cached surface-edge checks that `compactRegion` and `compactIfNeeded` use to avoid splitting a tool-call/result pair. The cache validates current membership by seq and answers both edges from one per-cut balance sequence; stale or missing seqs and orphan results reject. - **`dsh-session`** validates positional replacement, complete provenance, and content-only single-node `tool/result` rewrites through its one surface manager. Its invariant companion treats fresh appended tool results as executions that require an open step and pending call; validated replacements remain turn-enclosed rewrites. -- **Wiring**: `examples/repl-agent/cordis.yml` loads zero-config `dsh-token-meter`, `dsh-compact-tool-result-prune`, then `dsh-compact-basic`; service-wide defaults make the composition usable without repeated numeric policy. +- **Wiring**: `examples/tui-agent/cordis.yml` loads zero-config `dsh-token-meter`, `dsh-compact-tool-result-prune`, then `dsh-compact-basic`; service-wide defaults make the composition usable without repeated numeric policy. ## Testing diff --git a/.agents/notes/implemented/feature/2026-06-25-ask-user-question.md b/.agents/notes/implemented/feature/2026-06-25-ask-user-question.md index 4227341aec..e1c5d03c08 100644 --- a/.agents/notes/implemented/feature/2026-06-25-ask-user-question.md +++ b/.agents/notes/implemented/feature/2026-06-25-ask-user-question.md @@ -20,7 +20,7 @@ Providers return `{ answers: [{ id, selected, custom? }] }`. `selected` is alway ## UI mappings -`dsh-stdio-demo`'s in-package readline module renders each question, shows each option's `description` on the next line, supports comma/space-separated numeric choices for `multi_select`, accepts free-form custom answers, and rejects pending questions on abort, provider disposal, or stdin EOF. A batched request is asked in order and resolved as one answer object. The stdio provider serializes simultaneous requests with an internal queue so only one prompt owns stdin at a time. +`dsh-tui` renders each question as a keyboard overlay, shows option descriptions, supports single- and multi-select choices plus free-form custom answers, and rejects pending questions on abort, provider disposal, or terminal shutdown. Batched and simultaneous requests are queued so one overlay owns keyboard focus at a time. `dsh-acp` provides the same seam for ACP sessions. It resolves the calling `Agent` through `ownedRecord`, requiring the forward session-map record at `agent.session.id` to own that exact agent object, and calls ACP `unstable_createElicitation` with a session-scoped form for each question. Single-select options become a `choice` string enum; `multi_select` options become a `choice` array enum; optionless questions use a required `custom` text field. If the client returns both `choice` and non-empty `custom`, the custom answer wins. ACP `decline`/`cancel`, a missing answer, a missing session, and a client without elicitation support all become structured `UserInteractionError`s. @@ -42,8 +42,8 @@ ACP elicitation is currently marked unstable in the SDK. The fallback is still s The feature gives the model a powerful pause primitive, so prompt guidance matters. The tool description tells the model to ask concise questions and use options when possible. Product policy can later wrap `tools/execute` to restrict when the tool is allowed, but the loop should not special-case it. -`dsh-user-interaction` and `dsh-tool-ask-user` both live in `packages/ui` because they form one product-facing human-interaction capability. `agent-core` does not load either the tool or a provider. `stdio-agent` opts into the seam, its readline provider, and the model-facing tool. `acp-agent` keeps only the `userInteraction` seam/provider by default: ACP elicitation support is still client-dependent, so an ACP leaf must opt into the model-facing tool deliberately once its client can complete elicitation requests. +`dsh-user-interaction` and `dsh-tool-ask-user` both live in `packages/ui` because they form one product-facing human-interaction capability. `agent-core` does not load either the tool or a provider. `dsh-tui-demo` opts into the seam, TUI provider, and model-facing tool. `acp-agent` keeps only the `userInteraction` seam/provider by default: ACP elicitation support is still client-dependent, so an ACP leaf must opt into the model-facing tool deliberately once its client can complete elicitation requests. ## Testing -Unit coverage pins provider registration/disposal, duplicate-provider rejection, abort-before-provider, empty-question rejection, structured tool errors through `ctx.tools.execute()`, batched answers, multi-select answers, custom answers, and the model schema including the removal of `value`, `recommended`, `allow_custom`, and `desc`. `dsh-stdio-demo` tests cover option descriptions, queued requests, EOF/abort cleanup, optionless free-form input, invalid option reprompts, duplicate multi-select numbers, and batched question flows. ACP bridge tests drive a real in-memory ACP connection with the real `ask_user_question` tool and verify selected-option, custom-overrides-choice, multi-select, and optionless free-form elicitation paths continue the agent loop. +Unit coverage pins provider registration/disposal, duplicate-provider rejection, abort-before-provider, empty-question rejection, structured tool errors through `ctx.tools.execute()`, batched answers, multi-select answers, custom answers, and the model schema including the removal of `value`, `recommended`, `allow_custom`, and `desc`. TUI tests cover option descriptions, queued requests, shutdown/abort cleanup, optionless free-form input, invalid choices, duplicate multi-select selections, and batched question flows. ACP bridge tests drive a real in-memory ACP connection with the real `ask_user_question` tool and verify selected-option, custom-overrides-choice, multi-select, and optionless free-form elicitation paths continue the agent loop. diff --git a/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md b/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md index d46579dee1..c78126e92a 100644 --- a/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md +++ b/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md @@ -21,7 +21,7 @@ The system-prompt assembly owns the canonical model-facing tool order, exactly w Scope is deliberately narrow: this fixes the REGISTRATION-ORDER race, not plugin behavior. A `system-prompt/assemble` listener may still add, remove, or rearrange tools — same as it may edit sections after their sort — and owns the determinism of what it emits; the waterfall contract already demands deterministic listeners (the reconstructability invariant would catch a listener that diverges between build and replay). -Config plumbing follows the `persona` precedent, and `toolOrder` sits beside it: the app configs (`dsh-stdio-demo`, `dsh-acp-demo`) accept the key and forward it through `dsh-agent-spine-demo` (whose schema is the intersection of the owners' schemas) to the `SystemPrompt` child. One schemastery footnote is load-bearing: a schemastery array defaults to `[]`, but an omitted `toolOrder` must stay ABSENT (= lexicographic) rather than become an explicitly-configured empty list (invalid — it lacks the rest entry), so every schema on the chain forces the default to `undefined`. +Config plumbing follows the `persona` precedent, and `toolOrder` sits beside it: the TUI, Headless, and ACP app configs accept the key and forward it through `dsh-agent-spine-demo` (whose schema is the intersection of the owners' schemas) to the `SystemPrompt` child. One schemastery footnote is load-bearing: a schemastery array defaults to `[]`, but an omitted `toolOrder` must stay ABSENT (= lexicographic) rather than become an explicitly-configured empty list (invalid — it lacks the rest entry), so every schema on the chain forces the default to `undefined`. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md b/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md index 60a5e3e3a0..64fa232831 100644 --- a/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md +++ b/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md @@ -155,7 +155,7 @@ If the complete logical result fits under the inline cap, no formatted spill art - The tools execute through `ctx.bash.resolve(request)` → `ctx.bash.run(spec)`, forward `exec.signal`, never call `ctx.bash.start()`, and never expose a bash task id. The bash request workdir comes from `exec.agent?.session.header.cwd` when available; the resolved `spec.workdir` drives execution and relative-path display. - The tools request `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam, parse only untruncated stdout within that cap, and treat over-cap or still-truncated raw output as a clear search failure; raw `rg` output is never exposed to the model. - Oversized complete formatted results are saved through `ctx.spillStore.saveText()` when available while inline results stay bounded; spill failure, a missing backend, or a missing owner preserves the inline result and reports the unsaved remainder — never an `isError`. -- The package README, the generated config catalog, and exported JSDoc document the Config fields and `SEARCH_*` codes; the repl-agent example ships the conditional tool plugin (the acp-agent tree waits on the snapshot re-record above); the fs group README records the `rg` availability and co-located bash/filesystem deployment requirements. +- The package README, the generated config catalog, and exported JSDoc document the Config fields and `SEARCH_*` codes; the tui-agent example ships the conditional tool plugin (the acp-agent tree waits on the snapshot re-record above); the fs group README records the `rg` availability and co-located bash/filesystem deployment requirements. ## Risks diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml index 34c342ffd3..bc77edbd0a 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.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 -2026-07-17-dedicated-full-screen-tui-front-door.md: 178b5ea44be67f820a8ea7fed8acb987dffb3f80 -2026-07-17-dedicated-full-screen-tui-front-door.zh.md: ac055bad1b7a692c7a980430fdbd1e34737a9994 +2026-07-17-dedicated-full-screen-tui-front-door.md: 8fbc5dddc029190b346075a65c9e7857187f3d2b +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 6ddc3523b7a7173013efe2ef15c5ca0e940929fb diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md index 178b5ea44b..8fbc5dddc0 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md @@ -6,7 +6,7 @@ English | [中文](2026-07-17-dedicated-full-screen-tui-front-door.zh.md) ## Problem -The line-oriented `@deepseek-ai/dsh-stdio` front door works in pipes and ordinary terminals, but a full-screen coding interface must own raw input, differential screen drawing, cursor state, overlays, and terminal restoration. Combining those contracts in one UI plugin couples the pipe-safe path to a TTY-only lifecycle and makes it unclear which terminal behavior a composition selects. +At the time this front door was introduced, the line-oriented agent handled pipes and ordinary terminals, but a full-screen coding interface had to own raw input, differential screen drawing, cursor state, overlays, and terminal restoration. Combining those contracts in one UI plugin would have coupled a stream-oriented path to a TTY-only lifecycle. The later [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) removes that line agent; this Note continues to own the TUI design. The interactive channel must remain a Cordis plugin over the same agent, session, tool, and user-interaction services as every other front door. It needs to resume durable history, follow compaction replacements, display tool-owned presentation, and restore the terminal on startup failure and disposal. A standalone chat application or a second agent composition would duplicate behavior outside the plugin graph. @@ -14,7 +14,7 @@ The interactive channel must remain a Cordis plugin over the same agent, session DeepSeek Harness ships [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) as a dedicated Cordis plugin. It owns terminal input and presentation only; agent lifecycle, session persistence, tool execution, and the model-facing question tool remain separate composition entries. The plugin requires both stdin and stdout to be TTYs and fails instead of silently changing to line-oriented behavior. -The app layer selects a concrete terminal front door before mounting it. `@deepseek-ai/dsh-stdio-demo` can resolve `auto` from the two process streams, while the `repl-agent` and `tui-agent` leaves explicitly select readline and TUI respectively. The TUI leaf reuses the repl-agent backend and tool composition through an asserted include patch, so the three runnable agent leaves remain symmetric without duplicating deployment choices. +The app layer has one terminal front door. `@deepseek-ai/dsh-tui-demo` mounts the TUI before the configured agent, and `examples/tui-agent` owns the interactive coding composition and Code Mode overlay directly. Non-interactive tasks use `@deepseek-ai/dsh-cli-demo`; ACP remains a separate editor protocol. The selected front door receives the exact generated or resumed `SessionId` used by the pre-created agent. It mounts before the agent composition, waits for the matching root agent, and enters full-screen mode only after that agent exists. A matching `agent-loop/config-start-failed` event is therefore reported before screen takeover and exits with status 1. @@ -42,7 +42,7 @@ The implemented [TUI terminal-state snapshot Agent Note](../testing/2026-07-18-t ## Consequences -- Interactive terminal work gains a stateful Markdown, card, plan, and question interface without changing the line-oriented protocol used by pipes and automation. -- The TUI carries a pi-tui dependency and a strict TTY requirement; non-TTY deployments select `@deepseek-ai/dsh-stdio` at composition time. +- Interactive terminal work has a stateful Markdown, card, plan, and question interface with no second terminal protocol to keep aligned. +- The TUI carries a pi-tui dependency and a strict TTY requirement; non-TTY deployments use the Headless app or a structured protocol. - Session projection makes resume and compaction consistent with the durable conversation, but one configured session owns the transcript and editor. - Tool packages extend terminal cards through their existing presentation methods without adding tool-specific branches to the TUI. diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md index ac055bad1b..6ddc3523b7 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -逐行输出的 `@deepseek-ai/dsh-stdio` 入口适用于管道和普通终端,但全屏编码界面必须负责原始输入、差分绘制、光标状态、浮层和终端恢复。把这两类契约合并到一个 UI 插件中,会迫使管道安全路径依赖仅适用于 TTY 的生命周期,也使组合无法明确表达所选终端行为。 +在本入口引入时,面向行的 agent 负责 pipe 与普通终端,但全屏 coding 界面必须负责原始输入、差分绘制、光标状态、浮层和终端恢复。把这两类契约合并到一个 UI 插件中,会迫使面向 stream 的路径依赖仅适用于 TTY 的生命周期。后续的[移除重复 agent 决策](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)移除了这个面向行 agent;本 Note 继续负责 TUI 设计。 交互通道必须继续作为 Cordis 插件,使用与其他入口相同的 agent(智能体)、会话、工具和用户交互服务。它需要恢复持久历史、跟随压缩替换、显示工具自有的呈现内容,并在启动失败和资源释放时恢复终端。独立聊天应用或第二套 agent 组合会在插件图之外重复实现这些行为。 @@ -14,7 +14,7 @@ Status: implemented DeepSeek Harness 将 [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 作为独立的 Cordis 插件交付。该插件只负责终端输入与呈现;agent 生命周期、会话持久化、工具执行以及模型可见的提问工具仍由不同组合项负责。插件要求 stdin 和 stdout 均为 TTY;条件不满足时会失败,不会静默切换为逐行输出。 -应用组合层在挂载前选择具体的终端入口。`@deepseek-ai/dsh-stdio-demo` 可以根据两个进程流通过 `auto` 作出选择,`repl-agent` 和 `tui-agent` 叶节点则分别明确选择 readline 与 TUI。TUI 叶节点通过带断言的 include patch 复用 repl-agent 的后端和工具组合,使三个可运行的 agent 叶节点保持对称,同时避免重复部署选项。 +应用组合层只有一个终端入口。`@deepseek-ai/dsh-tui-demo` 在已配置 agent 之前挂载 TUI,`examples/tui-agent` 直接拥有交互式 coding 组装及其 Code Mode overlay。非交互任务使用 `@deepseek-ai/dsh-cli-demo`;ACP 仍是独立的编辑器协议。 所选入口接收预创建 agent 使用的同一个新建或恢复 `SessionId`。入口先于 agent 组合挂载,等待相符的根 agent 出现,然后才进入全屏模式。因此,相符的 `agent-loop/config-start-failed` 事件会在接管屏幕前报告,并以状态码 1 退出。 @@ -42,7 +42,7 @@ agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调 ## 后果 -- 交互式终端获得带状态的 Markdown、卡片、计划和提问界面,同时不会改变管道与自动化使用的逐行协议。 -- TUI 会引入 pi-tui 依赖并严格要求 TTY;非 TTY 部署在组合时选择 `@deepseek-ai/dsh-stdio`。 +- 交互式终端拥有带状态的 Markdown、卡片、计划和提问界面,无需再对齐第二套终端协议。 +- TUI 会引入 pi-tui 依赖并严格要求 TTY;非 TTY 部署使用 Headless app 或结构化协议。 - 会话投影使恢复和压缩与持久会话保持一致,但只有一个已配置会话拥有 transcript 和编辑器。 - 工具包通过既有呈现方法扩展终端卡片,无需在 TUI 中增加工具专用分支。 diff --git a/.agents/notes/implemented/process/2026-06-11-quality-gates.md b/.agents/notes/implemented/process/2026-06-11-quality-gates.md index 1a1cfe5b54..84c3da7b95 100644 --- a/.agents/notes/implemented/process/2026-06-11-quality-gates.md +++ b/.agents/notes/implemented/process/2026-06-11-quality-gates.md @@ -15,7 +15,7 @@ Every AGENTS.md promise gets a command that exits non-zero, wired into git hooks - jscpd detects cross-file clones in package production TypeScript and repository scripts; narrow source-range exceptions document deliberately parallel implementations. - Per-file 100% coverage on `packages/*/*/src` (v8); unreachable defensive guards carry `/* v8 ignore */ ` with stated reasons instead of deletion. - knip (dead code/deps), publint (package correctness), workspace constraints (workspace rules: private, cordis peer+dev, uniform version, ESM), and a NodeNext consumer typecheck for built package declarations. -- lefthook pre-commit (lint staged, typecheck, vendor-manifest guard) and pre-push (tests, hygiene); CI runs the full matrix on node 22.19/24/26 plus a demo smoke test driving the echo-agent end to end. +- lefthook pre-commit (lint staged, typecheck, vendor-manifest guard) and pre-push (tests, hygiene); CI runs the full matrix on node 22.19/24/26 plus built application smokes for the Headless, TUI, ACP, JSON-RPC, workflow, and code-runtime entry paths. ## Consequences diff --git a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md index f4a5d43f96..42eb4228b6 100644 --- a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md +++ b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md @@ -38,4 +38,4 @@ Performance (measured at migration time on the dev NFS filesystem; single-digit- On a fast local disk pnpm's content-addressed store typically wins on cold/warm installs and, especially, on **disk footprint** across multiple checkouts (one global store hardlinked into every `node_modules` vs Yarn copying ~279 MB per worktree — some devs regularly keep ~10 or more worktrees for this repo). That dedup advantage did **not** show in the migration-time numbers above because the test store and `node_modules` sat on different filesystems, defeating hardlinks; on a single-filesystem dev box or CI cache it applies. The honest summary: install speed on our NFS dev filesystem is a wash within noise; the move is justified by ecosystem alignment, phantom-dependency safety, and cross-checkout disk dedup — not by a raw install-time win. -All quality gates (constraints, typecheck, lint, doc-sync, test:coverage at 100%, build, knip, publint, echo-agent demo smoke) pass unchanged on pnpm, which is the correctness proof that the linker swap introduced no phantom-dependency breakage. +All quality gates (constraints, typecheck, lint, doc-sync, test:coverage at 100%, build, knip, publint, and built application smokes) pass on pnpm, which is the correctness proof that the linker swap introduces no phantom-dependency breakage. diff --git a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.md b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.md index 4f3fafe59d..7969f0e80c 100644 --- a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.md +++ b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.md @@ -26,15 +26,16 @@ Every graph page declares one maintenance mode: ### First shipped index -The first index links ten relationship surfaces. Package topology and tool-package affordances live in the existing generated catalogs that already own those facts; the remaining focused diagrams are generated by `scripts/gen-doc-graphs.ts`. +The index links eleven relationship surfaces. Package topology and tool-package affordances live in the existing generated catalogs that already own those facts; the remaining focused diagrams are generated by `scripts/gen-doc-graphs.ts`. | Graph | Maintenance mode | Source of truth | |---|---|---| | [module dependency graph](../../../../docs/module-graph.md) | generated | `packages/*/*/package.json` peer dependencies plus package group paths | | [tool schema catalog and package map](../../../../docs/tool-catalog.md) | generated | boot-harvested tool schemas plus tool-package service/effect metadata | | [capability seams and core services](../../../../docs/capability-seams.md) | hybrid generated | Cordis service declarations plus a role manifest in `gen-doc-graphs.ts` | -| [echo-agent app composition](../../../../examples/echo-agent/composition.md) | hybrid generated | `examples/echo-agent/cordis.yml` plugin list plus curated app/bundle expansion | -| [repl-agent app composition](../../../../examples/repl-agent/composition.md) | hybrid generated | `examples/repl-agent/cordis.yml` plugin list plus curated app/bundle expansion | +| [tui-agent app composition](../../../../examples/tui-agent/composition.md) | hybrid generated | `examples/tui-agent/cordis.yml` plugin list plus curated app/bundle expansion | +| [headless-agent app composition](../../../../examples/headless-agent/composition.md) | hybrid generated | `examples/headless-agent/cordis.yml` plugin list plus curated app/bundle expansion | +| [cordis-agent app composition](../../../../examples/cordis-agent/composition.md) | hybrid generated | `examples/cordis-agent/cordis.yml` plugin list plus curated app/bundle expansion | | [acp-agent app composition](../../../../examples/acp-agent/composition.md) | hybrid generated | `examples/acp-agent/cordis.yml` plugin list plus curated app/bundle expansion | | [event producer/consumer matrix](../../../../docs/event-producer-consumer.md) | hybrid generated | Cordis event declarations, AST-scanned `ctx.on/emit/parallel/serial/waterfall` sites, and explicit dynamic dispatch overrides | | [agent turn and step lifecycle](../../../../docs/agent-lifecycle.md) | curated | architecture.md loop lifecycle, Cordis catalog links, and session event semantics | diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md index 59f4c347eb..507641a99a 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md @@ -13,7 +13,7 @@ Set `engines.node` to `^22.19.0 || >=24.0.0` and test the keyless CI compatibili Two Node features gate the source runtime: - **`node:sqlite`** — `packages/session-persistence/session-persistence-sqlite` does a top-level `import { DatabaseSync } from 'node:sqlite'`. The module dropped its `--experimental-sqlite` flag requirement at **22.13** (LTS) and **23.4** (Current); before those, importing it throws at load. -- **Native TypeScript type-stripping** — the `packages/examples/stdio-demo/tests/built-bin.e2e.ts` smoke boots the published `lib/bin.js` under plain `node` (no tsx) and loads the example's `.ts` plugins (`mock-llm.ts`, `echo-tool.ts`). Type-stripping is the default from **22.18** (LTS) and **23.6** (Current); before those it needs `--experimental-strip-types`. +- **Native TypeScript type-stripping** — the built-mode `examples/headless-agent/tests/keyless-smoke.e2e.ts` smoke boots `dsh-cli-demo`'s published `lib/bin.js` under plain `node` (no tsx) and loads the example's `.ts` test adapter (`cli-mock-llm.ts`). Type-stripping is the default from **22.18** (LTS) and **23.6** (Current); before those it needs `--experimental-strip-types`. Those source features clear on the 22.x line at **22.18**, but the installed Pi adapter dependency raises the advertised LTS floor. `@deepseek-ai/dsh-llm-pi-ai` depends on `@earendil-works/pi-ai@0.79.3`, whose package declares `engines.node >=22.19.0`, so the LTS floor is **22.19**. The 24.x branch remains `>=24.0.0`. The disjoint range excludes Node 23 entirely: Node 23.0–23.5 still has at least one flagged source feature, and the 23 line is non-LTS/EOL, so advertising `>=23.6` would add a dead release line and a CI leg no deployment should use. diff --git a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md index ecea052387..d8d4015b0d 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md +++ b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md @@ -24,7 +24,7 @@ If an LLM adapter browser or dynamic model-picker needs this signal later, reint ## Verification -`llm/adapter-change` and its emits are gone and the regenerated cordis catalog is fresh; HMR-safety holds (disposing a contributing fiber removes the adapter); `tools/change` and `system-prompt/change` remain documented and tested; and no production path changed observable behavior — the ACP snapshot expected outputs and the echo-agent smoke are byte-unchanged. +`llm/adapter-change` and its emits are gone and the regenerated cordis catalog is fresh; HMR-safety holds (disposing a contributing fiber removes the adapter); `tools/change` and `system-prompt/change` remain documented and tested; and the ACP snapshots plus the keyless Headless Loader smoke pin the unchanged production paths. ## Consequences diff --git a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md index bde3efcc75..c44201b4e6 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md +++ b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md @@ -2,6 +2,8 @@ Status: implemented +The later [redundant-agent removal](2026-07-20-remove-stdio-and-echo-agents.md) supersedes this package-placement decision and removes the folded package, app, and line-oriented surface entirely. + ## Problem The readline UI was a whole package (`@deepseek-ai/dsh-ui-stdio` under `packages/support/`) whose only runtime importer was the app package `@deepseek-ai/dsh-stdio-demo`. The examples reach the readline UI by loading the app, never by composing the helper themselves; every other repo reference was mechanical or descriptive surface that existed BECAUSE the package boundary existed — manifest and tsconfig entries, generated module-graph rows, dependency-graph and README rows, and doc comments naming the package. The ui group README recorded the support placement rationale ("exists chiefly for the examples and the coverage gate — `ui/` is reserved for surfaces shipped as product"), which left a standing tension: a shipped product app depending on a support package documented as NOT product surface. diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml new file mode 100644 index 0000000000..91e9b078ad --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.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 +2026-07-20-remove-stdio-and-echo-agents.md: 2aba8193710c96d3726b91062bfa43d039b4cabf +2026-07-20-remove-stdio-and-echo-agents.zh.md: 2c3916683f4743384a2ce4104319da26145837fe diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md new file mode 100644 index 0000000000..2aba819371 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md @@ -0,0 +1,45 @@ +# Agent Note: Remove the stdio and Echo agents + +Status: implemented + +English | [中文](2026-07-20-remove-stdio-and-echo-agents.zh.md) + +## Problem + +DeepSeek Harness exposed two redundant product agents beside the TUI and Headless coding agents. The line-oriented stdio agent duplicated terminal interaction and non-interactive execution with a mixed prompt/output protocol. Echo duplicated Headless as a network-free mock model plus one teaching tool, making a test fixture into a user-facing agent and the default quick-start path. + +Both agents carried support surfaces beyond their leaf configurations. Stdio owned a UI plugin, app package, SDK interface, REPL leaf, prompt protocol, and Loader tests. Echo owned a runnable command, mock adapter, tool, CI demo gate, graph entry, teaching references, and a shared test fixture. Keeping any of those product paths would preserve the redundant agent indirectly. + +Standard input and output remain protocol boundaries for ACP, JSON-RPC, MCP, and child processes. Deterministic model adapters also remain valid inside tests. Those mechanisms do not justify a line-oriented or mock-only product agent. + +## Decision + +The stdio and Echo agents are removed without compatibility packages, modes, commands, or aliases. The stdio UI and app packages, `examples/repl-agent`, `examples/echo-agent`, `demo:repl`, `demo:echo`, their dedicated tests, and supporting manifests, gates, graphs, and documentation entries are deleted. + +The remaining application roles are explicit: + +- [`@deepseek-ai/dsh-tui-demo`](../../../../packages/examples/tui-demo/README.md) owns terminal-interactive execution. `examples/tui-agent` owns the complete coding composition, Code Mode overlay, PTY coverage, and terminal snapshots. +- [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) owns non-interactive execution. `examples/headless-agent` owns the real-model one-shot composition, replay snapshots, generic real-agent suites, and test-only keyless Loader fixtures. +- [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) and `@deepseek-ai/dsh-jsonrpc` own their framed protocol integrations. + +The SDK project model and create/config workflows replace the `stdio` run-interface option with `tui`; generated TUI projects compose `@deepseek-ai/dsh-tui` and create or resume one exact session. Repository-facing demo documentation requires a DeepSeek API key and leads with the real Headless or TUI agents. + +Keyless validation is test-owned. The Headless Loader smoke uses a fixture adapter to exercise a real tool round trip, the CLI built-bin suite pins output, persistence, failure, and signal semantics, and package-specific Loader tests keep deterministic adapters beside their scenarios. None is exposed as a runnable mock agent. + +## Verification + +TUI and Headless Loader coverage run the real app packages in source and built modes. TUI uses a pseudo-terminal; Headless proves its task/result and tool-call contracts. Generated graphs and repository searches reject stale package, command, leaf, and SDK-interface references. + +## Alternatives considered + +- **Keep the line agent only for pipes** — rejected because Headless has a bounded task contract, format-pure stdout, durable completion, and process exit status. +- **Keep Echo as the keyless quick start** — rejected because the first product experience should exercise the real model and supported coding agent, not a scripted adapter with a bespoke tool. +- **Keep Echo only as a CI demo command** — rejected because test-owned Headless fixtures cover the same Loader and built-artifact boundaries without preserving a mock product leaf. +- **Remove every stdio or mock mechanism** — rejected because framed protocols, process I/O, and deterministic test adapters are independent infrastructure, not the removed agents. + +## Consequences + +- Interactive and non-interactive product execution each have one owner and one runnable coding leaf. +- The repository has no keyless user-facing agent demo; local agent demos require `DEEPSEEK_API_KEY`. +- CI retains keyless real-entry coverage through test fixtures rather than a product command. +- Existing stdio-agent configurations, Echo commands, and SDK `--interface=stdio` invocations fail instead of being translated. diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md new file mode 100644 index 0000000000..2c3916683f --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md @@ -0,0 +1,45 @@ +# Agent Note: 移除 stdio 和 Echo agent + +Status: implemented + +[English](2026-07-20-remove-stdio-and-echo-agents.md) | 中文 + +## 问题 + +DeepSeek Harness 在 TUI 和 Headless coding agent 之外,还提供了两个重复的产品 agent(智能体)。面向行的 stdio agent 使用混合的提示符/输出协议,同时重复实现终端交互与非交互执行。Echo 则以无需联网的 mock 模型加一个教学工具重复实现 Headless,把测试 fixture(测试前置数据)变成面向用户的 agent 和默认快速上手路径。 + +两个 agent 的配套实现都不止叶节点配置。stdio 拥有 UI 插件、app 包(package)、SDK 接口、REPL 叶节点、提示符协议和 Loader 测试。Echo 拥有可运行命令、mock 适配器、工具、CI 演示门禁、图谱条目、教学引用和共享测试 fixture。保留其中任何产品路径,都会间接保留这个重复的 agent。 + +标准输入输出仍是 ACP、JSON-RPC、MCP 和子进程的协议边界。确定性模型适配器也仍可用于测试。这些机制不足以成为保留面向行或仅使用 mock 的产品 agent 的理由。 + +## 决策 + +彻底移除 stdio 和 Echo agent,不提供兼容包、模式、命令或别名。删除 stdio UI 包与 app 包、`examples/repl-agent`、`examples/echo-agent`、`demo:repl`、`demo:echo`、各自的专属测试,以及相关的 manifest(元数据清单)、门禁、图谱和文档条目。 + +保留的应用角色均有明确归属: + +- [`@deepseek-ai/dsh-tui-demo`](../../../../packages/examples/tui-demo/README.md) 负责终端交互式执行。`examples/tui-agent` 拥有完整 coding 组装、Code Mode 覆盖层、PTY 覆盖和终端快照。 +- [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) 负责非交互式执行。`examples/headless-agent` 拥有真实模型的单次任务组装、回放快照、通用真实 agent 测试套件,以及仅供测试使用的无密钥 Loader fixture。 +- [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) 和 `@deepseek-ai/dsh-jsonrpc` 负责各自的分帧协议集成。 + +SDK 工程模型与 create/config 工作流将 `stdio` 运行接口选项替换为 `tui`;生成的 TUI 工程组合 `@deepseek-ai/dsh-tui`,并创建或恢复一个确切会话。仓库中的演示文档要求 DeepSeek API key,并优先引导到真实的 Headless 或 TUI agent。 + +无密钥验证由测试负责。Headless Loader 冒烟测试使用 fixture 适配器验证真实工具往返;CLI built-bin 测试套件固定输出、持久化、失败和信号语义;各包专属的 Loader 测试则将确定性适配器放在对应场景旁。其中任何一项都不会作为可运行的 mock agent 对外暴露。 + +## 验证 + +TUI 与 Headless 的 Loader 覆盖以源码和构建产物两种模式运行真实 app 包。TUI 使用伪终端;Headless 验证任务/结果契约和工具调用契约。生成图谱与仓库搜索会拒绝陈旧的包、命令、叶节点和 SDK 接口引用。 + +## 曾考虑的替代方案 + +- **仅为 pipe 保留面向行 agent**:不予采纳,因为 Headless 已提供有界任务契约、格式纯净的 stdout、持久完成边界和进程退出状态。 +- **保留 Echo 作为无密钥快速上手路径**:不予采纳,因为首次产品体验应使用真实模型和受支持的 coding agent,而不是带专用工具的脚本化适配器。 +- **只为 CI 演示命令保留 Echo**:不予采纳,因为由测试持有的 Headless fixture 可以覆盖相同的 Loader 和构建产物边界,无需保留 mock 产品叶节点。 +- **移除所有 stdio 或 mock 机制**:不予采纳,因为分帧协议、进程 I/O 和确定性测试适配器是独立基础设施,并不是被移除的 agent。 + +## 后果 + +- 交互式与非交互式产品执行分别只有一个归属方和一个可运行的 coding 叶节点。 +- 仓库没有面向用户的无密钥 agent 演示;本地 agent 演示需要 `DEEPSEEK_API_KEY`。 +- CI 通过测试 fixture 保留针对真实入口的无密钥覆盖,而不是依赖产品命令。 +- 既有 stdio agent 配置、Echo 命令和 SDK `--interface=stdio` 调用会直接失败,不会被转换。 diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml index c208a1e553..133198a4d2 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.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 -2026-07-18-tui-terminal-state-snapshots.md: 192e872ab63cf4ff8a121ea0a2ee9345379cfa26 -2026-07-18-tui-terminal-state-snapshots.zh.md: 9766a8087632daa1be0dcfb191696dbad354ff68 +2026-07-18-tui-terminal-state-snapshots.md: 8e86588f69fdb9d615232252ecf57309d440f1cd +2026-07-18-tui-terminal-state-snapshots.zh.md: b70a46830f44e9da663e30745fcdb7ad281592da diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md index 192e872ab6..8e86588f69 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md @@ -21,7 +21,7 @@ TUI coverage has four complementary layers: 3. `examples/tui-agent/tests/tui.snapshot.ts` replays committed JSONL session logs through the production agent loop and real tools, then compares the resulting semantic terminal state. 4. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the real Loader composition in a PTY, drives a scripted conversation through streaming and `ask_user_question`, and verifies startup, input, exit, failure reporting, and terminal restoration. -The runnable TUI has its own `examples/tui-agent` leaf beside the readline `repl-agent` and `acp-agent` leaves. It reuses the repl-agent backend and tool composition through an asserted include patch while fixing the shared terminal app to `ui.mode: tui`; TUI snapshots and PTY tests live with that leaf. +The runnable TUI has its own `examples/tui-agent` leaf beside the Headless and ACP leaves. It owns the interactive coding backends and tools directly and loads `@deepseek-ai/dsh-tui-demo`; TUI snapshots and PTY tests live with that leaf. The [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) owns this consolidation. ### Recorded-session replay diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md index 9766a80876..b70a46830f 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md @@ -21,7 +21,7 @@ TUI 覆盖分为四个互补层次: 3. `examples/tui-agent/tests/tui.snapshot.ts` 通过生产 agent loop 和真实工具回放已提交的 JSONL 会话日志,再比较生成的语义终端状态。 4. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 在 PTY 中启动真实 Loader 组合,驱动一段经过流式输出和 `ask_user_question` 的脚本化会话,并验证启动、输入、退出、失败报告和终端恢复。 -可运行 TUI 在 `examples/tui-agent` 中拥有独立叶节点,与 readline `repl-agent` 和 `acp-agent` 叶节点并列。它通过带断言的 include patch 复用 repl-agent 的后端与工具组合,只把共享终端应用固定为 `ui.mode: tui`;TUI 快照和 PTY 测试也归属这个叶节点。 +可运行 TUI 在 `examples/tui-agent` 中拥有独立叶节点,与 Headless 和 ACP 叶节点并列。它直接拥有交互式 coding 后端与工具,并加载 `@deepseek-ai/dsh-tui-demo`;TUI 快照和 PTY 测试也归属这个叶节点。[移除重复 agent 的决策](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)负责此次整合。 ### 已录制会话回放 diff --git a/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.i18n.yaml b/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.i18n.yaml index c876ddc68f..f64160a5a0 100644 --- a/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.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 -2026-07-14-sdk-developer-projects.md: 1be9abcad1e51a1b9a1406f21ce60073427576e0 -2026-07-14-sdk-developer-projects.zh.md: a8ba1d658f78484a46a7148a4e2ff1b073a3e9f2 +2026-07-14-sdk-developer-projects.md: aa5cf64d7dd33dea229d74c2ae45a9244ee70e3c +2026-07-14-sdk-developer-projects.zh.md: 8f7d1de5b16f38019c802f07eda701cee72deb4f diff --git a/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md b/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md index 1be9abcad1..aa5cf64d7d 100644 --- a/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md +++ b/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md @@ -44,7 +44,7 @@ The table is the developer-visible support set for this phase. A `required` feat | Feature | Create state | Feature options | Constraints and relationships | |---|---|---|---| | `provider` | required | `deepseek` (default) / `custom` | DeepSeek collects an API key; custom also collects a base URL, and a CLI option may override the model name | -| `app` | required | `stdio` (default) / `acp` / `embed` | Selects the run interface | +| `app` | required | `tui` (default) / `acp` / `embed` | Selects the run interface | | `spine` | required | `default` | Timer, the LLM seam, session storage, system prompt, the tool registry, the agent registry, and the agent loop | | `bash` | required | `local` (default) / `sandbox` | The two feature options are exclusive and independent of the run interface, and both install the model-facing bash tool; sandbox installs the local sandbox provider and sandboxed bash backend | | `persistence` | required | `jsonl` (default) / `sqlite` | Every project selects exactly one persistence backend | @@ -59,9 +59,9 @@ The table is the developer-visible support set for this phase. A `required` feat | `hooks` | optional | `claude` (default) / `codex`, multiple | Each feature option creates a separate editable configuration file | | `guard` | optional | `repeat-tool` | Provides repeated-tool-call reminders | | `timeout-policy` | optional | `default` | Applies a uniform policy to tools that declare timeout budgets | -| `ask-user` | optional | `default` | Provides the `ask_user_question` tool; only `acp` and `stdio` can select it because those two feature options provide the injected user-interaction service | +| `ask-user` | optional | `default` | Provides the `ask_user_question` tool; only `acp` and `tui` can select it because those two feature options provide the injected user-interaction service | -Both `bash` feature options apply to ACP, stdio, and embed and are not selected by the run interface. The sandbox feature option writes no active config key and therefore keeps `dsh-bash-sandbox`'s `read-only` default. Generated `cordis.yml` includes a commented example that developers can change explicitly to `workspace-write`: +Both `bash` feature options apply to ACP, TUI, and embed and are not selected by the run interface. The sandbox feature option writes no active config key and therefore keeps `dsh-bash-sandbox`'s `read-only` default. Generated `cordis.yml` includes a commented example that developers can change explicitly to `workspace-write`: ```yaml - id: bash @@ -72,11 +72,11 @@ Both `bash` feature options apply to ACP, stdio, and embed and are not selected # workspaceRoot: !!js process.cwd() ``` -Feature contributions reference only single-plugin npm packages and never bundle packages such as `agent-spine-demo`, `stdio-demo`, or `acp-demo`. Plugins outside the table are not managed by create in this phase; advanced developers may still compose them by editing the ordinary project files directly. +Feature contributions reference only single-plugin npm packages and never bundle packages such as `agent-spine-demo`, `tui-demo`, or `acp-demo`. Plugins outside the table are not managed by create in this phase; advanced developers may still compose them by editing the ordinary project files directly. ## Generated project -With default answers, an npm project uses the DeepSeek provider, the stdio interface, local bash, JSONL persistence, and the preselected hmr, fs, todo, and skill features. Its initial tree is: +With default answers, an npm project uses the DeepSeek provider, the TUI interface, local bash, JSONL persistence, and the preselected hmr, fs, todo, and skill features. Its initial tree is: ```text my-agent/ @@ -106,7 +106,7 @@ Generated `package.json` provides the following scripts. `dev`, `build`, `start` `dsh-sdk start` and `dsh-sdk dev` accept a module target and forward arguments after `--` unchanged to the project entrypoint. Generic argument parsing uses Node `parseArgs()` with zero schema: valued flags use `--key=value`, bare flags become `true`, and `--no-*` becomes `false`. -- Stdio projects pass the selected model through `--model=` and create or resume an agent according to optional `--resume=`; +- TUI projects pass the selected model through `--model=` and create or resume an agent according to optional `--resume=`; - ACP uses protocol `session/load` - Embed uses the model written into the generated code. diff --git a/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.zh.md b/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.zh.md index a8ba1d658f..8f7d1de5b1 100644 --- a/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.zh.md +++ b/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.zh.md @@ -44,7 +44,7 @@ create 还提供一次 `none / plugin / tool` 选择。`plugin` 固定生成 `pl | 功能 | create 状态 | 功能选项 | 限制与关系 | |---|---|---|---| | `provider` | required | `deepseek`(默认)/ `custom` | DeepSeek 收集 API key;custom 另收集 base URL,模型名可由 CLI 参数覆盖 | -| `app` | required | `stdio`(默认)/ `acp` / `embed` | 选择运行接口 | +| `app` | required | `tui`(默认)/ `acp` / `embed` | 选择运行接口 | | `spine` | required | `default` | timer、LLM seam、会话存储、系统提示词、工具注册表、agent 注册表,以及 agent loop | | `bash` | required | `local`(默认)/ `sandbox` | 两个功能选项互斥、与运行接口正交,且都安装面向模型的 bash 工具;sandbox 安装本地沙箱提供方和沙箱 bash 后端 | | `persistence` | required | `jsonl`(默认)/ `sqlite` | 每个工程恰好选择一个持久化后端 | @@ -59,9 +59,9 @@ create 还提供一次 `none / plugin / tool` 选择。`plugin` 固定生成 `pl | `hooks` | optional | `claude`(默认)/ `codex`,可多选 | 各功能选项生成独立的可编辑配置文件 | | `guard` | optional | `repeat-tool` | 提供重复工具调用提醒 | | `timeout-policy` | optional | `default` | 对声明超时预算的工具执行统一策略 | -| `ask-user` | optional | `default` | 提供 `ask_user_question` 工具;注入的 user-interaction 服务由 acp/stdio 两个功能选项提供,因此仅这两个接口可选 | +| `ask-user` | optional | `default` | 提供 `ask_user_question` 工具;注入的 user-interaction 服务由 acp/tui 两个功能选项提供,因此仅这两个接口可选 | -`bash` 的两个功能选项都适用于 ACP、stdio 和 embed,不由运行接口决定。sandbox 功能选项不写任何生效的配置键,因而沿用 `dsh-bash-sandbox` 的 `read-only` 默认值;生成的 `cordis.yml` 保留注释示例,开发者可以显式改为 `workspace-write`: +`bash` 的两个功能选项都适用于 ACP、TUI 和 embed,不由运行接口决定。sandbox 功能选项不写任何生效的配置键,因而沿用 `dsh-bash-sandbox` 的 `read-only` 默认值;生成的 `cordis.yml` 保留注释示例,开发者可以显式改为 `workspace-write`: ```yaml - id: bash @@ -72,11 +72,11 @@ create 还提供一次 `none / plugin / tool` 选择。`plugin` 固定生成 `pl # workspaceRoot: !!js process.cwd() ``` -功能贡献只引用单插件 NPM 包,绝不引用 `agent-spine-demo`、`stdio-demo`、`acp-demo` 这类组合 NPM 包。表格之外的插件不由本期 create 管理;开发者仍可直接编辑普通工程文件进行高级组合。 +功能贡献只引用单插件 NPM 包,绝不引用 `agent-spine-demo`、`tui-demo`、`acp-demo` 这类组合 NPM 包。表格之外的插件不由本期 create 管理;开发者仍可直接编辑普通工程文件进行高级组合。 ## 生成工程 -使用默认答案创建 npm 工程时,provider 为 DeepSeek,运行接口为 stdio,bash 为 local,持久化为 JSONL,hmr、fs、todo 与 skill 处于选中状态。初始目录树为: +使用默认答案创建 npm 工程时,provider 为 DeepSeek,运行接口为 TUI,bash 为 local,持久化为 JSONL,hmr、fs、todo 与 skill 处于选中状态。初始目录树为: ```text my-agent/ @@ -106,7 +106,7 @@ my-agent/ `dsh-sdk start` 与 `dsh-sdk dev` 可以接收模块 target,并把 `--` 后的参数原样转发给工程入口。通用参数解析使用 Node `parseArgs()` 的零 schema 模式:带值 flag 采用 `--key=value`,bare flag 转换为 `true`,`--no-*` 转换为 `false`。 -- stdio 工程通过 `--model=` 传入所选 model,并根据可选的 `--resume=` 创建或恢复 agent; +- TUI 工程通过 `--model=` 传入所选 model,并根据可选的 `--resume=` 创建或恢复 agent; - acp 使用协议 `session/load` - embed 使用生成代码中的 model。 diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index 5c51209113..a138c5b4b4 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -5,7 +5,7 @@ description: Use before pushing, force-pushing, marking ready for review, claimi # DSH Pre-Push Checks -Use this skill to choose and run the smallest sufficient verification set before a `deepseek-harness` push. Do not treat the local pre-push hook as the full CI contract: CI also runs coverage, build, demo smoke, and built-bin smoke. +Use this skill to choose and run the smallest sufficient verification set before a `deepseek-harness` push. Do not treat the local pre-push hook as the full CI contract: CI also runs coverage, build, and built-bin smoke. ## First Steps @@ -54,7 +54,7 @@ pnpm run test:snapshot Run built-bin smoke tests after `pnpm run build` when app packages, app boot, package runtime imports, bin entries, loader behavior, or published artifact paths change. ```sh -pnpm exec vitest run --config vitest.e2e.config.ts packages/examples/stdio-demo/tests/built-bin.e2e.ts packages/examples/cli-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts +DSH_EXAMPLE_MODE=lib pnpm exec vitest run --config vitest.e2e.config.ts examples/headless-agent/tests/keyless-smoke.e2e.ts examples/tui-agent/tests/tui-keyless-smoke.e2e.ts packages/examples/cli-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts ``` Run real e2e when behavior depends on a real model/API, tool-use loop, ACP integration, prompt injection, or end-to-end agent UX. If `.env` is available, use it; do not print secrets. diff --git a/AGENTS.md b/AGENTS.md index ab793320aa..860f60f1e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,8 +27,8 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// cordis/ self-referential toolset: the agent inspects/mounts plugins in its own runtime hooks/ Claude Code / Codex hook bridges + shared wire-protocol library session-persistence/ persistence seam + JSONL/SQLite backends - ui/ ACP/stdio/TUI/JSON-RPC bridges; boot, approval, interaction plugins - examples/ demo bundles (agent-spine + stdio/CLI/ACP/JSON-RPC bins) leaves load + ui/ ACP/TUI/JSON-RPC bridges; boot, approval, interaction plugins + examples/ demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load support/ dev/test infrastructure packages util/ zero-dependency utilities python/ Python SDK and bundled runtime (see python/README.md) @@ -58,9 +58,7 @@ pnpm run build # tsc emits lib/types, tsdown bundles runtime pnpm run hygiene # knip + publint + workspace constraints + NodeNext consumer check pnpm run doc-sync # all documentation gates; see the doc-sync script in package.json pnpm run website:build # VitePress build (doubles as the site's dead-link check) -pnpm run demo:echo # mock-model REPL, no key needed -pnpm run demo:repl # real REPL coding agent (needs DEEPSEEK_API_KEY) -pnpm run demo:headless -- "task" # one-shot agent (needs DEEPSEEK_API_KEY) +pnpm run demo:headless "task" # one-shot agent (needs DEEPSEEK_API_KEY) pnpm run demo:tui # full-screen TUI coding agent (needs DEEPSEEK_API_KEY) pnpm run demo:cordis # self-referential demo: the agent modifies its own runtime (needs key) pnpm run demo:acp # ACP server agent (needs DEEPSEEK_API_KEY) @@ -86,12 +84,7 @@ pnpm run website:build pnpm run verify-module-graph pnpm run build pnpm run hygiene -out=$(printf 'echo ci smoke\n' | pnpm run demo:echo 2>&1) -printf '%s\n' "$out" | grep -q '\[tool call\] echo({"text":"ci smoke"})' -printf '%s\n' "$out" | grep -q '\[tool result\] ECHO: CI SMOKE' -test -n "$(find .sessions -path '.sessions/cwd-*/main-session-*.jsonl.zstd' -type f -print -quit)" -rm -rf .sessions -pnpm exec vitest run --config vitest.e2e.config.ts packages/examples/stdio-demo/tests/built-bin.e2e.ts packages/examples/cli-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts +DSH_EXAMPLE_MODE=lib pnpm exec vitest run --config vitest.e2e.config.ts examples/headless-agent/tests/keyless-smoke.e2e.ts examples/tui-agent/tests/tui-keyless-smoke.e2e.ts packages/examples/cli-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts ``` `test:coverage`, not `test`, is the gate ([why](docs/testing.md)); report only commands actually run. diff --git a/README.i18n.yaml b/README.i18n.yaml index 64e212ff3a..d78213a292 100644 --- a/README.i18n.yaml +++ b/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 -README.md: ef9a3a8832d1eaa35ec5f0fed1780ab27e8ff37c -README.zh.md: a30d6db4b04f23559c36a7aba80b4feb2962a1c6 +README.md: 32958db0e74bd14d6d41e8d7886b8d3257fe0f59 +README.zh.md: b28b175a8296347a7bed05b4e53c0d75dc51efed diff --git a/README.md b/README.md index ef9a3a8832..32958db0e7 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,11 @@ This monorepo is built on the [Cordis](https://github.com/cordiverse/cordis) fra ```sh pnpm install pnpm run test # vitest -pnpm run demo:echo # keyless mock-model REPL -pnpm run demo:repl # readline coding agent (needs DEEPSEEK_API_KEY) -pnpm run demo:tui # full-screen TUI coding agent (needs DEEPSEEK_API_KEY) -pnpm run demo:headless -- "task" # one-shot coding agent (needs DEEPSEEK_API_KEY) -pnpm run demo:cordis # self-referential agent demo (needs DEEPSEEK_API_KEY) -pnpm run demo:acp # ACP server agent demo (needs DEEPSEEK_API_KEY) +# Agent demos require DEEPSEEK_API_KEY. +pnpm run demo:tui # full-screen TUI coding agent +pnpm run demo:headless "task" # one-shot coding agent +pnpm run demo:cordis # self-referential agent demo +pnpm run demo:acp # ACP server agent demo ``` For humans, start with the [development guide](docs/development.md) for local setup, hooks, environment variables, and quality gates, then read the [architecture design](docs/architecture.md) and [documentation graph index](docs/graph-atlas.md) before package work. Local context lives in [packages/](packages/) and [vendor/](vendor/). diff --git a/README.zh.md b/README.zh.md index a30d6db4b0..b28b175a82 100644 --- a/README.zh.md +++ b/README.zh.md @@ -11,12 +11,11 @@ ```sh pnpm install pnpm run test # vitest -pnpm run demo:echo # keyless mock-model REPL -pnpm run demo:repl # readline coding agent (needs DEEPSEEK_API_KEY) -pnpm run demo:tui # full-screen TUI coding agent (needs DEEPSEEK_API_KEY) -pnpm run demo:headless -- "task" # one-shot coding agent (needs DEEPSEEK_API_KEY) -pnpm run demo:cordis # self-referential agent demo (needs DEEPSEEK_API_KEY) -pnpm run demo:acp # ACP server agent demo (needs DEEPSEEK_API_KEY) +# Agent demos require DEEPSEEK_API_KEY. +pnpm run demo:tui # full-screen TUI coding agent +pnpm run demo:headless "task" # one-shot coding agent +pnpm run demo:cordis # self-referential agent demo +pnpm run demo:acp # ACP server agent demo ``` 面向开发者:先读[开发指南](docs/development.md),了解本地环境搭建、钩子、环境变量与质量门禁,动手改 package 之前再读[架构设计](docs/architecture.md)和[文档关系图索引](docs/graph-atlas.md)。局部上下文见 [packages/](packages/) 与 [vendor/](vendor/)。 diff --git a/docs/architecture.md b/docs/architecture.md index 92a9939957..11c89b2884 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -158,7 +158,7 @@ Some seams bend the template deliberately: LLM combines interface and consumer b ### Bundles And Apps -`dsh-agent-spine-demo` bundles the default spine ([README](../packages/examples/agent-spine-demo/README.md)). `dsh-stdio-demo` selects `dsh-tui` for interactive terminals and line-oriented `dsh-stdio` for pipes; `dsh-cli-demo` runs one persisted headless turn with format-pure stdout; `dsh-acp-demo` adds stdout-pure ACP over JSON-RPC ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK supplies its default only without an explicit config channel and drives `dsh-jsonrpc` over line-delimited JSON-RPC ([Python SDK](../python/README.md)). Deployments remain thin leaves with swappable backends and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). +`dsh-agent-spine-demo` bundles the default spine ([README](../packages/examples/agent-spine-demo/README.md)). `dsh-tui-demo` owns the interactive full-screen terminal; `dsh-cli-demo` runs one persisted headless turn with format-pure stdout; `dsh-acp-demo` adds stdout-pure ACP over JSON-RPC ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK supplies its default only without an explicit config channel and drives `dsh-jsonrpc` over line-delimited JSON-RPC ([Python SDK](../python/README.md)). Deployments remain thin leaves with swappable backends and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). ### Where New Behavior Goes diff --git a/docs/capability-seams.md b/docs/capability-seams.md index ea49b0ab47..d14ae006b8 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -49,11 +49,12 @@ flowchart LR pkg_tool_todo["tool-todo"] pkg_user_interaction["user-interaction"] svc_userInteraction["ctx.userInteraction
Human question/answer seam"] - pkg_stdio_demo["stdio-demo"] + pkg_tui["tui"] pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] pkg_skill_local["skill-local"] svc_agents["ctx.agents
Agent service"] + pkg_tui_demo["tui-demo"] svc_agentLoop["ctx.agentLoop
Concrete loop driver"] pkg_agent_spine_demo["agent-spine-demo"] pkg_bash["bash"] @@ -136,7 +137,6 @@ flowchart LR pkg_skill_local --> svc_skills pkg_spill --> svc_spillStore pkg_spill_local --> svc_spillStore - pkg_stdio_demo --> svc_userInteraction pkg_subagent --> svc_subagents pkg_subagent_acp --> svc_subagents pkg_subagent_fork --> svc_subagents @@ -146,6 +146,7 @@ flowchart LR pkg_token_meter --> svc_tokenMeter pkg_tool_bash --> svc_bashEnv pkg_tools --> svc_tools + pkg_tui --> svc_userInteraction pkg_user_interaction --> svc_userInteraction pkg_web --> svc_web pkg_web_fetch_local --> svc_web @@ -158,8 +159,8 @@ flowchart LR svc_agents --> pkg_acp svc_agents --> pkg_agent_loop svc_agents --> pkg_cli_demo - svc_agents --> pkg_stdio_demo svc_agents --> pkg_subagent_inprocess + svc_agents --> pkg_tui_demo svc_approval --> pkg_tool_bash svc_approval --> pkg_tools svc_bash --> pkg_hooks_claude @@ -213,8 +214,8 @@ flowchart LR svc_tools --> pkg_tool_todo svc_tools --> pkg_tool_web svc_userInteraction --> pkg_acp - svc_userInteraction --> pkg_stdio_demo svc_userInteraction --> pkg_tool_ask_user + svc_userInteraction --> pkg_tui svc_web --> pkg_tool_web svc_workflows --> pkg_tool_workflow svc_fs -. event gate .-> pkg_fs_policy @@ -231,9 +232,9 @@ flowchart LR | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads and relationship traces. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | -| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | +| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | -| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | +| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d024efb5bb..93277828cc 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -888,90 +888,6 @@ export interface Config { Source: [`packages/spill/spill-policy/src/index.ts:45`](../packages/spill/spill-policy/src/index.ts) -## `@deepseek-ai/dsh-stdio` - -Requires: `agents` · `userInteraction` - -```ts config-catalog -/** Serializable plugin configuration (cordis-native, schemastery). */ -export interface Config { - /** Banner printed once on start, before the first `> ` prompt. */ - welcome?: string - /** Exact shared agent/session identity stdin drives. Defaults to `'main'`. */ - sessionId?: string -} -``` - -Source: [`packages/ui/stdio/src/index.ts:34`](../packages/ui/stdio/src/index.ts) - -## `@deepseek-ai/dsh-stdio-demo` - -```ts config-catalog -/** - * App config: the swappable per-demo values, each routed to where the app wires - * it. `provider`/`model`/`resumeSessionId` configure the pre-created `main` agent (through - * {@link @deepseek-ai/dsh-agent-spine-demo}'s forwarded `agents` list); `persona` is - * the deployment persona (forwarded to the system-prompt plugin); `toolOrder` - * is the explicit model-facing tool order (forwarded to the system-prompt plugin); - * fresh sessions use `process.cwd()` as their workspace cwd; resumed sessions - * keep their persisted cwd. `persistenceRoot` is the JSONL backend's directory; - * `welcome` is the UI banner and `ui` configures terminal mode/presentation. - */ -export interface Config { - /** Provider route for the `main` agent. */ - provider: string - /** Model name for the `main` agent (must have a registered adapter). */ - model: string - /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ - maxParallelToolCalls?: number - /** Deployment persona (the system-prompt plugin's `persona` config). */ - persona?: string - /** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */ - toolOrder?: string[] - /** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */ - tools?: ToolsConfig - /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ - dshHome?: string - /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ - persistenceRoot?: string - /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ - persistenceCompression?: JsonlCompression - /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */ - welcome?: string - /** Terminal front-door selection and pi-tui presentation settings. */ - ui?: UiConfig - /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */ - skills?: agentCore.SkillConfig - /** Model-facing bash tool config forwarded through agent-core. */ - toolBash?: NonNullable - /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ - toolTasks?: NonNullable - /** - * If set, the pre-created agent RESUMES this persisted session id instead of - * starting fresh. Sourced from an env var in the leaf `cordis.yml` - * (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`). - */ - resumeSessionId?: string - /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ - workspaceContext: agentCore.Config['workspaceContext'] -} - -/** App-level terminal selection with nested TUI presentation settings. */ -export interface UiConfig { - /** Select a concrete front door or infer it from the process streams. */ - mode?: TerminalMode - /** Settings forwarded only when the pi-tui front door is selected. */ - tui?: uiTui.TuiConfig -} - -/** Terminal front door selected by the app bundle. */ -export type TerminalMode = 'auto' | 'readline' | 'tui' -``` - -Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) - -Source: [`packages/examples/stdio-demo/src/index.ts:78`](../packages/examples/stdio-demo/src/index.ts) - ## `@deepseek-ai/dsh-subagent-acp` Requires: `subagents` @@ -1354,6 +1270,50 @@ export interface TuiConfig { Source: [`packages/ui/tui/src/index.ts:101`](../packages/ui/tui/src/index.ts) +## `@deepseek-ai/dsh-tui-demo` + +```ts config-catalog +/** App config routed to the spine, TUI, configured agent, and JSONL backend. */ +export interface Config { + /** Provider route for the `main` agent. */ + provider: string + /** Model name for the `main` agent; a matching adapter must be registered. */ + model: string + /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ + maxParallelToolCalls?: number + /** Deployment persona forwarded to the system-prompt plugin. */ + persona?: string + /** Explicit model-facing tool order forwarded to the system-prompt plugin. */ + toolOrder?: string[] + /** Tool-registry presentation config forwarded through agent-spine-demo. */ + tools?: ToolsConfig + /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ + dshHome?: string + /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ + persistenceRoot?: string + /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ + persistenceCompression?: JsonlCompression + /** TUI subtitle rendered on start. Defaults to `ready.`. */ + welcome?: string + /** Full-screen TUI presentation settings. */ + ui?: uiTui.TuiConfig + /** Skill registry, local-provider, and model-facing consumer config. */ + skills?: agentCore.SkillConfig + /** Model-facing bash tool config forwarded through agent-spine-demo. */ + toolBash?: NonNullable + /** Generic background-task controls forwarded through agent-spine-demo; set false to omit them. */ + toolTasks?: NonNullable + /** Persisted session id to resume instead of creating a fresh session. */ + resumeSessionId?: string + /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ + workspaceContext: agentCore.Config['workspaceContext'] +} +``` + +Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) + +Source: [`packages/examples/tui-demo/src/index.ts:31`](../packages/examples/tui-demo/src/index.ts) + ## `@deepseek-ai/dsh-user-approval` ```ts config-catalog diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 5bcb3bac1d..070cc45f93 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.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 -adding-a-tool.md: 68a8449bc189497b917efe678837d757f85aaf75 -adding-a-tool.zh.md: 003534e04550bfbee6740aa3b6bee02ac2cdc237 +adding-a-tool.md: a45315dc0ec92ab28963c2aca32dffcf5f778dcd +adding-a-tool.zh.md: f574957ddd0e42cedc93ddc0f3270110a8f110c5 diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 68a8449bc1..a45315dc0e 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -2,7 +2,7 @@ English | [中文](adding-a-tool.zh.md) -How to give the model a new capability. Reference implementations: `examples/echo-agent/src/echo-tool.ts` (minimal) and `packages/bash/tool-bash` (production-grade, three-package seam). +How to give the model a new capability. The minimal shape below shows the contract; `packages/bash/tool-bash` is the production-grade three-package seam. ## The minimal shape diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index 003534e045..f574957ddd 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -2,7 +2,7 @@ [English](adding-a-tool.md) | 中文 -如何为模型赋予一项新能力。参考实现:`examples/echo-agent/src/echo-tool.ts`(最小化)和 `packages/bash/tool-bash`(生产级,由三个包(package)构成的 seam)。 +如何为模型赋予一项新能力。下文的最小形态展示这项契约;`packages/bash/tool-bash` 是生产级、由三个包(package)构成的 seam。 ## 最小形态 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 5b7f226916..940fb81a0f 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.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 -extension-cookbook.md: 37793e4e76bf5171c759ca78be473912101bd9f4 -extension-cookbook.zh.md: 8f170f225b55721c78ef27c0e87e481b5cb00f64 +extension-cookbook.md: 32877b6170fd75ec901dda7cc0aec6b5a92e6cc6 +extension-cookbook.zh.md: d6bb6075b47867dcb5a848c835062ef4d9af5d45 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 37793e4e76..32877b6170 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -87,7 +87,7 @@ export function apply(ctx: Context) { ## Runnable wirings -Six runnable leaves load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool, `pnpm run demo:echo`), [`examples/repl-agent`](../../examples/repl-agent) (DeepSeek V4 + coding tools through a line-oriented readline REPL, `pnpm run demo:repl`), [`examples/tui-agent`](../../examples/tui-agent) (the same coding composition through full-screen pi-tui, `pnpm run demo:tui`), [`examples/headless-agent`](../../examples/headless-agent) (the same capability class behind a one-shot task and DSH-native output, `pnpm run demo:headless -- "task"`), [`examples/cordis-agent`](../../examples/cordis-agent) (self-inspection and dynamic plugin mounting, `pnpm run demo:cordis`), and [`examples/acp-agent`](../../examples/acp-agent) (an ACP server over JSON-RPC stdio, `pnpm run demo:acp`). The terminal leaves load [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo), the headless leaf loads [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo), the ACP leaf loads [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and all three app packages share [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo). +Four runnable leaves load their plugin trees from `cordis.yml`: [`examples/tui-agent`](../../examples/tui-agent) (DeepSeek coding tools through the full-screen TUI, `pnpm run demo:tui`), [`examples/headless-agent`](../../examples/headless-agent) (the coding capabilities behind a one-shot task and DSH-native output, `pnpm run demo:headless "task"`), [`examples/cordis-agent`](../../examples/cordis-agent) (self-inspection and dynamic plugin mounting through the TUI, `pnpm run demo:cordis`), and [`examples/acp-agent`](../../examples/acp-agent) (an ACP server over JSON-RPC stdio, `pnpm run demo:acp`). Interactive leaves load [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo), non-interactive leaves load [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo), the ACP leaf loads [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and all three app packages share [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo). ## The feature → mechanism map diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 8f170f225b..d6bb6075b4 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -87,7 +87,7 @@ export function apply(ctx: Context) { ## 可运行的组装示例 -六个可运行叶子从 `cordis.yml` 加载各自的插件树:[`examples/echo-agent`](../../examples/echo-agent)(mock 模型 + echo 工具,`pnpm run demo:echo`)、[`examples/repl-agent`](../../examples/repl-agent)(DeepSeek V4 + coding 工具,通过面向行的 readline REPL 交互,`pnpm run demo:repl`)、[`examples/tui-agent`](../../examples/tui-agent)(通过全屏 pi-tui 复用相同的 coding 组装,`pnpm run demo:tui`)、[`examples/headless-agent`](../../examples/headless-agent)(同类能力通过单次任务和 DSH 原生输出运行,`pnpm run demo:headless -- "task"`)、[`examples/cordis-agent`](../../examples/cordis-agent)(自我检查和动态插件挂载,`pnpm run demo:cordis`)与 [`examples/acp-agent`](../../examples/acp-agent)(通过 JSON-RPC stdio 暴露的 ACP 服务器,`pnpm run demo:acp`)。终端叶子加载 [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo),headless 叶子加载 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo),ACP 叶子加载 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),三个 app 包都通过 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) 共享主干。 +四个可运行叶子从 `cordis.yml` 加载各自的插件树:[`examples/tui-agent`](../../examples/tui-agent)(通过全屏 TUI 运行的 DeepSeek coding 工具,`pnpm run demo:tui`)、[`examples/headless-agent`](../../examples/headless-agent)(通过单次任务和 DSH 原生输出运行的 coding 能力,`pnpm run demo:headless "task"`)、[`examples/cordis-agent`](../../examples/cordis-agent)(通过 TUI 进行自我检查和动态插件挂载,`pnpm run demo:cordis`)与 [`examples/acp-agent`](../../examples/acp-agent)(通过 JSON-RPC stdio 暴露的 ACP 服务器,`pnpm run demo:acp`)。交互式叶子加载 [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo),非交互式叶子加载 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo),ACP 叶子加载 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),三个 app 包都通过 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) 共享主干。 ## 功能→机制映射 diff --git a/docs/core-data-structures/user-interaction.md b/docs/core-data-structures/user-interaction.md index dcca6355e9..46e17f3f83 100644 --- a/docs/core-data-structures/user-interaction.md +++ b/docs/core-data-structures/user-interaction.md @@ -1,6 +1,6 @@ # User Interaction -The user-interaction seam of [dsh-user-interaction](../../packages/ui/user-interaction). It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. UI surfaces provide the active `UserInteractionProvider`: `dsh-stdio-demo` selects keyboard-driven `dsh-tui` overlays or `dsh-stdio` readline prompts, and `dsh-acp` maps questions to ACP form elicitations. +The user-interaction seam of [dsh-user-interaction](../../packages/ui/user-interaction). It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. UI surfaces provide the active `UserInteractionProvider`: `dsh-tui` uses keyboard-driven overlays, and `dsh-acp` maps questions to ACP form elicitations. Source: [`packages/ui/user-interaction/src/index.ts`](../../packages/ui/user-interaction/src/index.ts) diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 2ca3f14fbc..8fa0107cfc 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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 -development.md: 94eb4f03329b574862a1ac1de2f8c1d4db4f4a0a -development.zh.md: b533aff43a66ff7cfc5dc61e5b9b224a01c51f12 +development.md: 3327d094a31ad9af62a23c9562cdfa03218961a5 +development.zh.md: cb1cb86f9f3a34c455844467a20dfdfd08e15098 diff --git a/docs/development.md b/docs/development.md index 94eb4f0332..3327d094a3 100644 --- a/docs/development.md +++ b/docs/development.md @@ -9,7 +9,7 @@ This onboarding guide helps project contributors get started with the local envi - Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md). - Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack. - Git. -- Optional: a DeepSeek API key for the REPL/ACP agent demos and real-API e2e tests. +- Optional: a DeepSeek API key for the TUI/Headless/ACP agent demos and real-API e2e tests. ## First-time setup @@ -63,7 +63,7 @@ lefthook is configured in `lefthook.yml` as an early local checkpoint before rev The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code. -These hooks do not exactly mirror CI. Notably, `pre-push` runs unit tests without coverage, while CI runs `pnpm run test:coverage`; CI also runs echo-agent and built-bin smoke tests and exercises the compatibility matrix on Node 22.19, 24, and 26. +These hooks do not exactly mirror CI. Notably, `pre-push` runs unit tests without coverage, while CI runs `pnpm run test:coverage`; CI also runs built-bin smoke tests and exercises the compatibility matrix on Node 22.19, 24, and 26. ## CI gates @@ -102,19 +102,13 @@ When changing package public behavior, update the relevant README or JSDoc in th ## Demos -The echo demo does not need API credentials: +The one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`: ```sh -pnpm run demo:echo +pnpm run demo:headless "summarize this workspace" ``` -The repl-agent demo uses the line-oriented readline front door and needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`: - -```sh -pnpm run demo:repl -``` - -The full-screen TUI reuses the repl-agent composition through the pi-tui front door and needs the same credentials: +The full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`: ```sh pnpm run demo:tui diff --git a/docs/development.zh.md b/docs/development.zh.md index b533aff43a..cb1cb86f9f 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -9,7 +9,7 @@ - Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。 - 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。 - Git。 -- 可选:一个 DeepSeek API key,用于 REPL/ACP(Agent Client Protocol) agent(智能体)演示和真实 API 的 e2e 测试。 +- 可选:一个 DeepSeek API key,用于 TUI/Headless/ACP(Agent Client Protocol) agent(智能体)演示和真实 API 的 e2e 测试。 ## 首次搭建 @@ -63,7 +63,7 @@ lefthook 在 `lefthook.yml` 中配置,作为评审前的本地早期检查点 vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。 -这些钩子并不与 CI 完全一致。特别是:`pre-push` 运行不带覆盖率的单元测试,而 CI 运行 `pnpm run test:coverage`;CI 还会运行 echo-agent 和 built-bin 冒烟测试,并在 Node 22.19、24 和 26 上执行兼容性矩阵。 +这些钩子并不与 CI 完全一致。特别是:`pre-push` 运行不带覆盖率的单元测试,而 CI 运行 `pnpm run test:coverage`;CI 还会运行 built-bin 冒烟测试,并在 Node 22.19、24 和 26 上执行兼容性矩阵。 ## CI 门禁 @@ -102,19 +102,13 @@ pnpm run hygiene # knip, publint, workspace constraints, and NodeNext dec ## 演示 -echo 演示不需要 API 凭证: +单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`: ```sh -pnpm run demo:echo +pnpm run demo:headless "summarize this workspace" ``` -repl-agent 示例使用面向行的 readline 前端,并需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`: - -```sh -pnpm run demo:repl -``` - -全屏 TUI 通过 pi-tui 前端复用 repl-agent 组装,并需要相同的凭证: +全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`: ```sh pnpm run demo:tui diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e7bfad234e..5af03f00f1 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,9 +7,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:150`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:159`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:150`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:159`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`tui`](../packages/ui/tui) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:314`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | | `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:267`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:207`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | @@ -18,8 +18,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:229`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:281`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:244`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:191`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:168`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:191`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:168`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`tui`](../packages/ui/tui) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:291`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:301`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | @@ -30,7 +30,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:43`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm-replay`](../packages/support/llm-replay) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/graph-atlas.md b/docs/graph-atlas.md index d477bd6d62..6050c4a60e 100644 --- a/docs/graph-atlas.md +++ b/docs/graph-atlas.md @@ -12,8 +12,6 @@ The process decision behind this index is recorded in [the documentation graph A | [module dependency graph](module-graph.md) | `generated` | | [tool schema catalog and package map](tool-catalog.md) | `generated` | | [capability seams and core services](capability-seams.md) | `hybrid generated` | -| [echo-agent app composition](../examples/echo-agent/composition.md) | `hybrid generated` | -| [repl-agent app composition](../examples/repl-agent/composition.md) | `hybrid generated` | | [tui-agent app composition](../examples/tui-agent/composition.md) | `hybrid generated` | | [headless-agent app composition](../examples/headless-agent/composition.md) | `hybrid generated` | | [cordis-agent app composition](../examples/cordis-agent/composition.md) | `hybrid generated` | diff --git a/docs/i18n/translation-prompt.md b/docs/i18n/translation-prompt.md index bfbb583303..e7943bedae 100644 --- a/docs/i18n/translation-prompt.md +++ b/docs/i18n/translation-prompt.md @@ -123,9 +123,9 @@ Follow the Good versions; these sentence-level examples illustrate error categor - Good: `A green gate does not mean the translation is correct.` ### Code block comments — never translate -- Source code block contains: `# readline coding agent (needs DEEPSEEK_API_KEY)` -- Bad: `# readline 编码 agent(需要 DEEPSEEK_API_KEY)` -- Good: `# readline coding agent (needs DEEPSEEK_API_KEY)` (byte-identical) +- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` +- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)` +- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (byte-identical) ### Language switcher — English to Chinese - Source: `English | [中文](README.zh.md)` diff --git a/docs/module-graph.md b/docs/module-graph.md index f8f44887db..f05130203d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -108,7 +108,6 @@ flowchart TD pkg_app_boot["app-boot"] pkg_jsonrpc["jsonrpc"] pkg_permission["permission"] - pkg_stdio["stdio"] pkg_tool_ask_user["tool-ask-user"] pkg_tui["tui"] pkg_user_approval["user-approval"] @@ -127,7 +126,7 @@ flowchart TD pkg_agent_spine_demo["agent-spine-demo"] pkg_cli_demo["cli-demo"] pkg_jsonrpc_demo["jsonrpc-demo"] - pkg_stdio_demo["stdio-demo"] + pkg_tui_demo["tui-demo"] end subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] @@ -474,12 +473,6 @@ flowchart TD pkg_jsonrpc --> pkg_scope pkg_jsonrpc --> pkg_session pkg_jsonrpc --> pkg_subagent - pkg_stdio --> pkg_agent - pkg_stdio --> pkg_agent_loop - pkg_stdio --> pkg_invariants - pkg_stdio --> pkg_llm - pkg_stdio --> pkg_session - pkg_stdio --> pkg_user_interaction pkg_tui --> pkg_agent pkg_tui --> pkg_agent_loop pkg_tui --> pkg_invariants @@ -536,20 +529,19 @@ flowchart TD pkg_cli_demo --> pkg_session_persistence_jsonl pkg_cli_demo --> pkg_tools pkg_cli_demo --> pkg_workspace_context - pkg_stdio_demo --> pkg_agent - pkg_stdio_demo --> pkg_agent_loop - pkg_stdio_demo --> pkg_agent_spine_demo - pkg_stdio_demo --> pkg_app_boot - pkg_stdio_demo --> pkg_invariants - pkg_stdio_demo --> pkg_llm - pkg_stdio_demo --> pkg_session - pkg_stdio_demo --> pkg_session_persistence_jsonl - pkg_stdio_demo --> pkg_stdio - pkg_stdio_demo --> pkg_tool_ask_user - pkg_stdio_demo --> pkg_tools - pkg_stdio_demo --> pkg_tui - pkg_stdio_demo --> pkg_user_interaction - pkg_stdio_demo --> pkg_workspace_context + pkg_tui_demo --> pkg_agent + pkg_tui_demo --> pkg_agent_loop + pkg_tui_demo --> pkg_agent_spine_demo + pkg_tui_demo --> pkg_app_boot + pkg_tui_demo --> pkg_invariants + pkg_tui_demo --> pkg_llm + pkg_tui_demo --> pkg_session + pkg_tui_demo --> pkg_session_persistence_jsonl + pkg_tui_demo --> pkg_tool_ask_user + pkg_tui_demo --> pkg_tools + pkg_tui_demo --> pkg_tui + pkg_tui_demo --> pkg_user_interaction + pkg_tui_demo --> pkg_workspace_context ``` | Package | Group | Depends on | @@ -638,7 +630,6 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | @@ -646,4 +637,4 @@ flowchart TD | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.md b/docs/postmortem/0001-acp-default-export-drops-inject.md index e024f4d698..10e88c390c 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.md +++ b/docs/postmortem/0001-acp-default-export-drops-inject.md @@ -24,7 +24,7 @@ The ACP server could not create or load a single session — the two RPCs an edi ## Root cause #1 — `export default apply` drops the plugin's `inject` (broke `session/new`) -`packages/ui/acp/src/index.ts` is a *namespace plugin*: it exports `name`, `inject`, `Config`, and `apply` as separate named exports — the same shape as every other plugin in the repo (`invariants`, `llm-deepseek`, `tool-bash`, `stdio-chat`, …). But it *also* ended with one extra line no other plugin had: +`packages/ui/acp/src/index.ts` is a *namespace plugin*: it exports `name`, `inject`, `Config`, and `apply` as separate named exports — the same shape as every other plugin in the repo (`invariants`, `llm-deepseek`, `tool-bash`, `tui`, …). But it *also* ended with one extra line no other plugin had: ```ts ignore-check export const name = 'acp' diff --git a/docs/testing.md b/docs/testing.md index 1fcefb4bc3..f2f87ec769 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -13,7 +13,7 @@ Every Vitest configuration mounts enabled `ctx.invariants` before ordinary roots ## The with-key policy: inference is cheap here -We are DeepSeek — do not ration real-API tests. A no-key test proves plumbing; only a with-key run proves the agent works against a real model. Write many: file-writing prompts, multi-turn conversations, tool use, cancellation mid-stream. Highest-value are **smoke tests** that boot the real example, send one real prompt, and check the world — they catch the "green unit tests, broken product" class that mocks structurally cannot ([postmortem 0001](postmortem/0001-acp-default-export-drops-inject.md)). The self-skip exists only so secretless CI and keyless contributors aren't blocked; it is not a cost signal. Every example ships a keyless smoke and — unless keyless-by-nature — a with-key smoke ([examples/AGENTS.md](../examples/AGENTS.md)). +We are DeepSeek — do not ration real-API tests. A no-key test proves plumbing; only a with-key run proves the agent works against a real model. Write many: file-writing prompts, multi-turn conversations, tool use, cancellation mid-stream. Highest-value are **smoke tests** that boot the real example, send one real prompt, and check the world — they catch the "green unit tests, broken product" class that mocks structurally cannot ([postmortem 0001](postmortem/0001-acp-default-export-drops-inject.md)). The self-skip exists only so secretless CI and keyless contributors aren't blocked; it is not a cost signal. Every example ships both a keyless smoke and a with-key smoke ([examples/AGENTS.md](../examples/AGENTS.md)). ## Prefer the real implementation over a mock diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index d2339d35a7..3caf1415fa 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -22,7 +22,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - | -| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | +| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflows`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | @@ -448,7 +448,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its Source: [`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. +The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. ## `@deepseek-ai/dsh-tool-tasks` diff --git a/docs/user/develop/basic/index.i18n.yaml b/docs/user/develop/basic/index.i18n.yaml index 22b03af93e..711715a5de 100644 --- a/docs/user/develop/basic/index.i18n.yaml +++ b/docs/user/develop/basic/index.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 -index.md: 5fa46806bc195ad2566fc0a29b45eb1dd7a68179 -index.zh.md: a6d238c12841c8c25b00376ee032e5db50fc6b4e +index.md: d7d657ff7b8cb9001dd5e9c3af658a7a3c45b5b7 +index.zh.md: 7a134f7aaed470b87ee8ca8978dd39593de2651b diff --git a/docs/user/develop/basic/index.md b/docs/user/develop/basic/index.md index 5fa46806bc..d7d657ff7b 100644 --- a/docs/user/develop/basic/index.md +++ b/docs/user/develop/basic/index.md @@ -122,24 +122,24 @@ Function form is sufficient in most cases. Use class form when the plugin provid ## Complete example -`examples/echo-agent/src/echo-tool.ts` is a plugin that registers a tool: +A minimal tool plugin registers its definition on `ctx.tools`: ```ts import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' -export const name = 'echo-tool' +export const name = 'greet-tool' export const inject = ['tools'] export function apply(ctx: Context) { ctx.tools.register(defineTool({ - name: 'echo', - description: 'Echo the given text back, uppercased.', + name: 'greet', + description: 'Greet the named person.', parameters: { - text: { type: 'string', required: true }, + name: { type: 'string', required: true }, }, async execute(args) { - return [{ type: 'text', text: `ECHO: ${args.text.toUpperCase()}` }] + return [{ type: 'text', text: `Hello, ${args.name}!` }] }, })) } diff --git a/docs/user/develop/basic/index.zh.md b/docs/user/develop/basic/index.zh.md index a6d238c128..7a134f7aae 100644 --- a/docs/user/develop/basic/index.zh.md +++ b/docs/user/develop/basic/index.zh.md @@ -122,24 +122,24 @@ export default class MyService extends Service { ## 完整示例 -参考仓库中的 `examples/echo-agent/src/echo-tool.ts`,这是一个注册 tool 的插件: +最小化的工具插件会在 `ctx.tools` 上注册其定义: ```ts import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' -export const name = 'echo-tool' +export const name = 'greet-tool' export const inject = ['tools'] export function apply(ctx: Context) { ctx.tools.register(defineTool({ - name: 'echo', - description: 'Echo the given text back, uppercased.', + name: 'greet', + description: 'Greet the named person.', parameters: { - text: { type: 'string', required: true }, + name: { type: 'string', required: true }, }, async execute(args) { - return [{ type: 'text', text: `ECHO: ${args.text.toUpperCase()}` }] + return [{ type: 'text', text: `Hello, ${args.name}!` }] }, })) } diff --git a/docs/user/develop/practice/llm-adapter.i18n.yaml b/docs/user/develop/practice/llm-adapter.i18n.yaml index 30805c97b4..8735e8d5a6 100644 --- a/docs/user/develop/practice/llm-adapter.i18n.yaml +++ b/docs/user/develop/practice/llm-adapter.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 -llm-adapter.md: f34fc9e1d5b59a323bb562764821ef910025880e -llm-adapter.zh.md: 3c781ae8a1a011e2f73d5f6de43f6f75e1fb549f +llm-adapter.md: 3e83289b8072ef231f83c0fa3cfe3260547b42fa +llm-adapter.zh.md: 92fcf9b22f4bb356ada4c46f9a03ef0cc2d159da diff --git a/docs/user/develop/practice/llm-adapter.md b/docs/user/develop/practice/llm-adapter.md index f34fc9e1d5..3e83289b80 100644 --- a/docs/user/develop/practice/llm-adapter.md +++ b/docs/user/develop/practice/llm-adapter.md @@ -131,10 +131,12 @@ The first argument lists the model names handled by the adapter. If `cordis.yml` - my-model-v1 - my-model-v2 -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' +- id: tui-agent + name: '@deepseek-ai/dsh-tui-demo' config: + provider: my-llm model: my-model-v1 # References the model registered above. + workspaceContext: false ``` ## Reference implementations @@ -143,9 +145,8 @@ The repository contains complete implementations: - `packages/llm/llm-deepseek/` — DeepSeek API adapter using the OpenAI-compatible format - `packages/llm/llm-pi-ai/` — Pi AI adapter using a different API format -- `examples/echo-agent/src/mock-llm.ts` — minimal local teaching adapter -Start with the mock adapter to study a complete chunk sequence without network behavior. +Compare the two shipped adapters to see the same harness contract implemented over different provider SDKs. ## Error handling diff --git a/docs/user/develop/practice/llm-adapter.zh.md b/docs/user/develop/practice/llm-adapter.zh.md index 3c781ae8a1..92fcf9b22f 100644 --- a/docs/user/develop/practice/llm-adapter.zh.md +++ b/docs/user/develop/practice/llm-adapter.zh.md @@ -131,10 +131,12 @@ ctx.llm.registerAdapter(['model-name-1', 'model-name-2'], adapter) - my-model-v1 - my-model-v2 -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' +- id: tui-agent + name: '@deepseek-ai/dsh-tui-demo' config: + provider: my-llm model: my-model-v1 # References the model registered above. + workspaceContext: false ``` ## 实战参考 @@ -143,9 +145,8 @@ ctx.llm.registerAdapter(['model-name-1', 'model-name-2'], adapter) - `packages/llm/llm-deepseek/` — DeepSeek API 适配器(OpenAI 兼容格式) - `packages/llm/llm-pi-ai/` — Pi AI 适配器(不同的 API 格式) -- `examples/echo-agent/src/mock-llm.ts` — 最简 mock 适配器(教学用) -mock 适配器是学习 StreamChunk 协议的最佳起点——它用纯本地逻辑演示了完整的 chunk 序列。 +对比这两个已交付的适配器,可以看到同一套 harness 契约如何在不同提供方 SDK 之上实现。 ## 错误处理 diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml index 9894ca95bc..cf2658bf4d 100644 --- a/docs/user/guide/config.i18n.yaml +++ b/docs/user/guide/config.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 -config.md: a3f56018fd43cc803c1710f97c29a77340a0b257 -config.zh.md: af661b9d7ef72e4085551202169e975bd0c3ec99 +config.md: 8958729d04224215ca420c3103d253a8a5783405 +config.zh.md: 530f2b335453d5064acdac28a60d7df51cd915f0 diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md index a3f56018fd..8958729d04 100644 --- a/docs/user/guide/config.md +++ b/docs/user/guide/config.md @@ -8,8 +8,8 @@ Harness uses `cordis.yml` to describe which plugins an agent loads and the confi The repository examples are runnable configurations and the most reliable starting points for a new project: -- [echo-agent](../../../examples/echo-agent/cordis.yml) uses a local mock model and needs no API key. -- [repl-agent](../../../examples/repl-agent/cordis.yml) combines the DeepSeek model, Bash, filesystem, compaction, subagents, and workflows. +- [tui-agent](../../../examples/tui-agent/cordis.yml) combines the DeepSeek model, Bash, filesystem, compaction, subagents, workflows, and the interactive TUI. +- [headless-agent](../../../examples/headless-agent/cordis.yml) exposes the coding composition as a one-shot task. - [acp-agent](../../../examples/acp-agent/cordis.yml) connects to editor clients over ACP. A minimal configuration is a list of plugin entries: @@ -22,10 +22,15 @@ A minimal configuration is a list of plugin entries: models: - deepseek-v4-flash -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: tui-agent + name: '@deepseek-ai/dsh-tui-demo' config: + provider: deepseek model: deepseek-v4-flash + workspaceContext: false ``` ## Plugin entries diff --git a/docs/user/guide/config.zh.md b/docs/user/guide/config.zh.md index af661b9d7e..530f2b3354 100644 --- a/docs/user/guide/config.zh.md +++ b/docs/user/guide/config.zh.md @@ -8,8 +8,8 @@ Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的 仓库中的示例就是可以运行的配置,也是新项目最可靠的起点: -- [echo-agent](../../../examples/echo-agent/cordis.yml) 使用本地 mock 模型,不需要 API key。 -- [repl-agent](../../../examples/repl-agent/cordis.yml) 组合 DeepSeek 模型、Bash、文件系统、压缩、子代理和工作流。 +- [tui-agent](../../../examples/tui-agent/cordis.yml) 组合 DeepSeek 模型、Bash、文件系统、压缩、子代理、工作流和交互式 TUI。 +- [headless-agent](../../../examples/headless-agent/cordis.yml) 以单次任务形式暴露 coding 组装。 - [acp-agent](../../../examples/acp-agent/cordis.yml) 通过 ACP 接入编辑器客户端。 最小配置由一组插件条目组成: @@ -22,10 +22,15 @@ Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的 models: - deepseek-v4-flash -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: tui-agent + name: '@deepseek-ai/dsh-tui-demo' config: + provider: deepseek model: deepseek-v4-flash + workspaceContext: false ``` ## 插件条目 diff --git a/docs/user/guide/index.i18n.yaml b/docs/user/guide/index.i18n.yaml index 6743abcdd4..e2b307201e 100644 --- a/docs/user/guide/index.i18n.yaml +++ b/docs/user/guide/index.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 -index.md: a20b1041e13b01b6b1d01a5baa8975d3e68c6aa0 -index.zh.md: 56ec50352218e2e28ad2dd7a6ef387376de75606 +index.md: b698b8aeee6cebff374e20ca0f76ddc9e75213c0 +index.zh.md: 337d246baa12ccf6d7a9656d1ea3b06002554c13 diff --git a/docs/user/guide/index.md b/docs/user/guide/index.md index a20b1041e1..b698b8aeee 100644 --- a/docs/user/guide/index.md +++ b/docs/user/guide/index.md @@ -14,10 +14,12 @@ Harness implements every capability an AI agent needs—including LLM calls, too config: apiKey: !!js process.env.DEEPSEEK_API_KEY -# Select the application template -- name: '@deepseek-ai/dsh-stdio-demo' +# Select the interactive application +- name: '@deepseek-ai/dsh-tui-demo' config: + provider: deepseek model: deepseek-v4-flash + workspaceContext: false ``` ## Who it is for diff --git a/docs/user/guide/index.zh.md b/docs/user/guide/index.zh.md index 56ec503522..337d246baa 100644 --- a/docs/user/guide/index.zh.md +++ b/docs/user/guide/index.zh.md @@ -14,10 +14,12 @@ Harness 将一个 AI Agent(智能体) 所需要的所有能力——LLM 调 config: apiKey: !!js process.env.DEEPSEEK_API_KEY -# Select the application template -- name: '@deepseek-ai/dsh-stdio-demo' +# Select the interactive application +- name: '@deepseek-ai/dsh-tui-demo' config: + provider: deepseek model: deepseek-v4-flash + workspaceContext: false ``` ## 适合谁 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index a4898be8e0..b3de74949b 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.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 -quickstart.md: acae2ac095e057971043c2bcece7a52d3ebc1c2c -quickstart.zh.md: 54643fe54e62dbbd3696362cb43ff8569577c53b +quickstart.md: 25ce51ee3d010d2eb800071b9697fc62857dace1 +quickstart.zh.md: e2e023670a999566e273d8893c42103dc273e7b1 diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index acae2ac095..25ce51ee3d 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -7,91 +7,52 @@ This guide gets an agent running in five minutes. ## Prerequisites - [Node.js](https://nodejs.org/) ^22.19 or >= 24 -- [pnpm](https://pnpm.io/) 11 (use Corepack to select the repository-pinned version) +- [pnpm](https://pnpm.io/) 11 through Corepack +- A [DeepSeek Platform](https://platform.deepseek.com/) API key ```sh -# Check versions -node -v # v22.19.x, or v24.x and newer +node -v corepack enable -pnpm -v # 11.x +pnpm -v ``` -## Step 1: run echo-agent - -echo-agent needs no API key and runs after dependencies are installed. +## Step 1: install and configure the API key ```sh -# Clone the repository git clone https://github.com/deepseek-harness/deepseek-harness.git cd deepseek-harness - -# Install dependencies pnpm install - -# Start echo-agent -pnpm run demo:echo ``` -The process prints: - -``` -echo-agent ready. Type a message ("echo " triggers the tool). -> -``` - -Enter: - -``` -> echo hello world -``` - -The model issues a tool call, and the echo tool returns the text in uppercase: - -``` -[tool call] echo({"text":"hello world"}) -[tool result] ECHO: HELLO WORLD -``` - -Your local environment is ready. - -## Step 2: use a real model - -Next, connect a real DeepSeek model and run the complete command-line agent. - -### Get an API key - -Get an API key from [DeepSeek Platform](https://platform.deepseek.com/). - -### Configure the environment - -Create a gitignored `.env` file in the repository root: +Create the gitignored repository-root `.env`: ```sh DEEPSEEK_API_KEY=sk-your-key-here ``` -### Start repl-agent +## Step 2: run one Headless task + +Run a non-interactive task and print its final answer: ```sh -pnpm run demo:repl +pnpm run demo:headless "summarize the architecture of this workspace" ``` -``` -agent REPL ready. Give it a coding task. -> +Headless runs one complete model/tool turn, persists the session, prints the result, and exits. Use `--output-format stream-json` when you need the canonical event stream. + +## Step 3: use the TUI + +Start the interactive coding agent: + +```sh +pnpm run demo:tui ``` -This is a complete coding assistant that can read and write files, run commands, and delegate subtasks. - -Try a task: - -``` -> Create hello.js in the current directory, print "Hello from Harness!", and run it -``` +The full-screen agent can read and write files, run commands, delegate subtasks, and track a plan. Try: `Create hello.js in the current directory, print "Hello from Harness!", and run it`. ## What happened -echo-agent and repl-agent use the same application framework (`@deepseek-ai/dsh-stdio-demo`). Their `cordis.yml` files select different plugins and configuration. Custom agents use the same composition model. +headless-agent uses the `@deepseek-ai/dsh-cli-demo` app; tui-agent uses the interactive `@deepseek-ai/dsh-tui-demo` app. Both load the same providerless agent spine, while their `cordis.yml` files select the DeepSeek model and capability plugins appropriate to each surface. ## Next steps diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index 54643fe54e..e2e023670a 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -7,93 +7,54 @@ ## 环境准备 - [Node.js](https://nodejs.org/) ^22.19 或 >= 24 -- [pnpm](https://pnpm.io/) 11(建议通过 Corepack 使用仓库固定的版本) +- 通过 Corepack 使用 [pnpm](https://pnpm.io/) 11 +- [DeepSeek Platform](https://platform.deepseek.com/) API key ```sh -# Check versions -node -v # v22.19.x, or v24.x and newer +node -v corepack enable -pnpm -v # 11.x +pnpm -v ``` -## 第一步:运行 echo-agent - -echo-agent 不需要 API key,装好依赖就能跑。 +## 第一步:安装并配置 API key ```sh -# Clone the repository git clone https://github.com/deepseek-harness/deepseek-harness.git cd deepseek-harness - -# Install dependencies pnpm install - -# Start echo-agent -pnpm run demo:echo ``` -启动后你会看到: - -``` -echo-agent ready. Type a message ("echo " triggers the tool). -> -``` - -试着输入: - -``` -> echo hello world -``` - -你会看到模型发起了一次 tool call(工具调用),echo 工具将文本转为大写并返回: - -``` -[tool call] echo({"text":"hello world"}) -[tool result] ECHO: HELLO WORLD -``` - -恭喜!环境没问题。 - -## 第二步:使用真实模型调用 - -接下来接入真实的 DeepSeek 模型,跑一个完整的命令行 Agent。 - -### 获取 API Key - -前往 [DeepSeek Platform](https://platform.deepseek.com/) 获取你的 API key。 - -### 配置环境变量 - -在仓库根目录创建 `.env` 文件(已被 gitignore): +在仓库根目录创建已被 Git 忽略的 `.env`: ```sh DEEPSEEK_API_KEY=sk-your-key-here ``` -### 启动 repl-agent +## 第二步:运行一个 Headless 任务 + +运行一个非交互式任务并打印最终回答: ```sh -pnpm run demo:repl +pnpm run demo:headless "summarize the architecture of this workspace" ``` -``` -agent REPL ready. Give it a coding task. -> +Headless 运行一个完整的模型/工具轮次,持久化会话,打印结果后退出。需要规范事件流时可使用 `--output-format stream-json`。 + +## 第三步:使用 TUI + +启动交互式 coding agent: + +```sh +pnpm run demo:tui ``` -这就是一个完整的编程助手,它能读写文件、跑命令、拆分子任务。 - -试着给它一个任务: - -``` -> Create hello.js in the current directory, print "Hello from Harness!", and run it -``` +这个全屏 Agent 可以读写文件、运行命令、分配子任务和跟踪计划。可以尝试:`Create hello.js in the current directory, print "Hello from Harness!", and run it`。 ## 回头看 -echo-agent 和 repl-agent 用的是同一个应用框架(`@deepseek-ai/dsh-stdio-demo`),区别只在 `cordis.yml`——换了哪些插件、填了什么配置。你以后定制自己的 Agent 也是同样的方式。 +headless-agent 使用 `@deepseek-ai/dsh-cli-demo` app,tui-agent 使用交互式 `@deepseek-ai/dsh-tui-demo` app。二者加载同一个 providerless agent spine,并通过各自的 `cordis.yml` 为对应 surface 选择 DeepSeek 模型和能力插件。 ## 下一步 -- [配置文件](./config.md) — 了解 `cordis.yml` 的完整语法 -- [开发插件](../develop/basic/) — 编写你自己的 tool 或后端 +- [配置文件](./config.md) — 了解 `cordis.yml` 的格式 +- [开发插件](../develop/basic/) — 编写自己的 tool 或后端 diff --git a/examples/AGENTS.md b/examples/AGENTS.md index 6b820368e4..a1f87ac8fb 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -11,9 +11,7 @@ Each example has both: - **Keyless:** boot the real `cordis.yml` through the Loader, drive it, and assert output and clean exit. Catches Loader/export-shape failures hand-mounted tests miss ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). - **With-key:** send a live-model prompt and verify external state, not the model's claim. Self-skip without `DEEPSEEK_API_KEY`; see [testing.md](../docs/testing.md). -Mock-only examples require only the keyless tier; state that exception in the test. - -Keyless stdio smokes use `@deepseek-ai/dsh-loader-smoke`; tests supply paths, environment, input, and assertions. Every checked-in test Cordis config lives under its corresponding `examples//` leaf. Map a package-owned config to `examples//tests/fixtures///cordis.yml`, keep its driver and assertions package-local, and declare every package it names in both root `tsconfig.json` references and `examples/package.json`. +Keyless process smokes use `@deepseek-ai/dsh-loader-smoke` for Loader launch resolution; terminal tests wrap that launch in a pseudo-terminal. Tests supply paths, environment, input, and assertions. Every checked-in test Cordis config lives under its corresponding `examples//` leaf. Map a package-owned config to `examples//tests/fixtures///cordis.yml`, keep its driver and assertions package-local, and declare every package it names in both root `tsconfig.json` references and `examples/package.json`. Do not inventory example tests here; the `tests/` trees and root scripts are authoritative. diff --git a/examples/README.md b/examples/README.md index 6524a36f7f..8578c5c25a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,37 +1,18 @@ # Examples -Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks the swappable backends (an LLM adapter, a bash executor), loads one app package, and may add optional product tools or demo-only mocks. The composition — the spine, the front-door cluster, and the boot glue — lives in the app packages ([`@deepseek-ai/dsh-stdio-demo`](../packages/examples/stdio-demo), [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo)) and the [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle they share. There is no `start.ts`; the `demo:*` scripts invoke each app package's `bin`. - -## echo-agent - -A mock model + echo tool on the stdio chat app — the all-mock skeleton. The leaf swaps `dsh-stdio-demo`'s LLM backend to a local `mock-echo` adapter and adds a local `echo` tool. Demonstrates: - -- A thin leaf `cordis.yml` loading the `@deepseek-ai/dsh-stdio-demo` app -- Registering a mock `LlmAdapter` (streaming scripted responses) -- Registering a tool via `ctx.tools.register()` -- "Swap the backend, keep the app" — the only difference from `repl-agent` is the adapter - -Run with: `pnpm run demo:echo`. When prompted, type "echo " to trigger a tool call round-trip. - -## repl-agent - -A coding agent with DeepSeek V4, the `read`/`write`/`edit` filesystem tools, the bash tool suite, `subagent` delegation, and the `todo_write` task tracker on the `@deepseek-ai/dsh-stdio-demo` app's readline front door. - -Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [repl-agent/README.md](repl-agent/README.md) for details. - -Run the Code Mode overlay with `pnpm run demo:code-mode`, or pass `acp` for the ACP example. See the [Code Mode example](repl-agent/README.md#code-mode) for its composition and a sample task. +Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks swappable backends, loads one app package, and may add optional product tools. The composition and boot glue live in [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo), [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo), and their shared [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle. There is no `start.ts`; the `demo:*` scripts invoke each app package's bin. ## headless-agent A non-interactive agent demo that accepts one positional task, runs one complete model/tool turn on the `@deepseek-ai/dsh-cli-demo` app, persists a fresh session, prints `text`, `json`, or `stream-json`, and exits. -Run with: `pnpm run demo:headless -- "task"` (needs `DEEPSEEK_API_KEY`). See [headless-agent/README.md](headless-agent/README.md) for the output contract, safety boundaries, and snapshot suite. +Run with: `pnpm run demo:headless "task"` (needs `DEEPSEEK_API_KEY`). See [headless-agent/README.md](headless-agent/README.md) for the output contract, safety boundaries, and snapshot suite. ## tui-agent -The full-screen terminal sibling of `repl-agent`: it reuses the same coding backends and tools while forcing the shared terminal app to `dsh-tui`. It is the home of TUI PTY and snapshot scenarios. +The interactive coding agent: DeepSeek V4, filesystem and bash tools, subagents, workflows, `todo_write`, compaction, and the full-screen TUI. It is also the home of TUI PTY and snapshot scenarios. -Run with: `pnpm run demo:tui` (needs `DEEPSEEK_API_KEY`). See [tui-agent/README.md](tui-agent/README.md) for controls and composition. +Run with: `pnpm run demo:tui` (needs `DEEPSEEK_API_KEY`). Run its Code Mode overlay with `pnpm run demo:code-mode`. See [tui-agent/README.md](tui-agent/README.md) for controls and composition. ## jsonrpc-agent diff --git a/examples/cordis-agent/README.md b/examples/cordis-agent/README.md index 310fbb30af..a0bb2188da 100644 --- a/examples/cordis-agent/README.md +++ b/examples/cordis-agent/README.md @@ -1,6 +1,6 @@ # cordis-agent -The self-referential harness demo: the coding spine (DeepSeek V4 + local bash on the stdio chat app) plus [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md), which hands the model three tools over the **live cordis runtime it is running inside** — inspect it, mount new plugins into it, and dispose them again. The `ctx.fs` and `ctx.web` services are mounted (provider-only, no model-facing file/web tools) so the plugins the agent writes have real capabilities to build on; Node built-ins are trapped in the sandbox and redirect to those services. The design (sandbox semantics, mount lifecycle, cross-mount composition, caveats) lives in [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). +The self-referential harness demo: the DeepSeek V4 coding spine on the full-screen TUI plus [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md), which hands the model three tools over the **live cordis runtime it is running inside** — inspect it, mount new plugins into it, and dispose them again. The `ctx.fs` and `ctx.web` services are mounted (provider-only, no model-facing file/web tools) so the plugins the agent writes have real capabilities to build on; Node built-ins are trapped in the sandbox and redirect to those services. The design (sandbox semantics, mount lifecycle, cross-mount composition, caveats) lives in [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). ## Run it diff --git a/examples/cordis-agent/composition.md b/examples/cordis-agent/composition.md index 499379482f..a812f71445 100644 --- a/examples/cordis-agent/composition.md +++ b/examples/cordis-agent/composition.md @@ -20,11 +20,11 @@ flowchart LR cfg --> plugin_cordis_web plugin_cordis_web_fetch_local["web-fetch-local
@deepseek-ai/dsh-web-fetch-local"] cfg --> plugin_cordis_web_fetch_local - plugin_cordis_stdio_agent["stdio-agent
@deepseek-ai/dsh-stdio-demo"] - cfg --> plugin_cordis_stdio_agent - plugin_cordis_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] - plugin_cordis_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_cordis_stdio_agent --> frontdoor_stdio["dsh-tui (TTY) / dsh-stdio (pipes)
pre-created main agent"] + plugin_cordis_tui_agent["tui-agent
@deepseek-ai/dsh-tui-demo"] + cfg --> plugin_cordis_tui_agent + plugin_cordis_tui_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] + plugin_cordis_tui_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] + plugin_cordis_tui_agent --> frontdoor_tui["@deepseek-ai/dsh-tui
pre-created main agent"] bundle_agent_core --> spine_llm["ctx.llm"] bundle_agent_core --> spine_sessions["ctx.sessions"] bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] @@ -41,7 +41,7 @@ flowchart LR | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `web` | `@deepseek-ai/dsh-web` | | `web-fetch-local` | `@deepseek-ai/dsh-web-fetch-local` | -| `stdio-agent` | `@deepseek-ai/dsh-stdio-demo` | +| `tui-agent` | `@deepseek-ai/dsh-tui-demo` | | `tool-cordis` | `@deepseek-ai/dsh-tool-cordis` | Source config: [`examples/cordis-agent/cordis.yml`](cordis.yml). diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index 7c7c010c3a..39b6cd3b48 100644 --- a/examples/cordis-agent/cordis.yml +++ b/examples/cordis-agent/cordis.yml @@ -1,4 +1,4 @@ -# Self-referential stdio demo: the coding spine plus tools to inspect the live +# Self-referential TUI demo: the coding spine plus tools to inspect the live # service/plugin/tool/mount/API/event state, mount a model-written plugin under # `cordis-dynamic`, and quiescently unmount it. The app bin loads the gitignored # root `.env` before reading the required DeepSeek key and optional base URL. @@ -45,8 +45,8 @@ name: '@deepseek-ai/dsh-web-fetch-local' # The app bundle pre-creates the self-referential demo's `main` agent. -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' +- id: tui-agent + name: '@deepseek-ai/dsh-tui-demo' config: provider: deepseek model: deepseek-v4-flash diff --git a/examples/cordis-agent/tests/keyless-smoke.e2e.ts b/examples/cordis-agent/tests/keyless-smoke.e2e.ts index 24cf2138fb..6e5cca3b08 100644 --- a/examples/cordis-agent/tests/keyless-smoke.e2e.ts +++ b/examples/cordis-agent/tests/keyless-smoke.e2e.ts @@ -1,27 +1,23 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' +import { runTuiPtySmoke } from '../../tui-agent/tests/pty-harness.ts' -/** - * Keyless Loader-path smoke for examples/cordis-agent: boot the real tree, - * including tool-cordis resolved by package name, then close stdin without a - * prompt and assert the banner. The dummy key never reaches a model call. - */ - -const binScript = fileURLToPath(new URL('../../../packages/examples/stdio-demo/src/bin.ts', import.meta.url)) +const binScript = fileURLToPath(new URL('../../../packages/examples/tui-demo/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -describe('cordis-agent keyless smoke (real cordis.yml via the Loader)', () => { - it('boots the full plugin tree incl. tool-cordis, prints its banner, and exits cleanly on EOF', async () => { - const { stdout } = await runLoaderSmoke({ +describe('cordis-agent keyless smoke (real Loader tree in a PTY)', () => { + it('boots the full tool-cordis tree and exits cleanly through the TUI', async () => { + const output = await runTuiPtySmoke({ label: 'cordis-agent', - tempDirPrefix: 'cordis-smoke-', + tempDirPrefix: 'cordis-agent-smoke-', binScript, configPath, tsconfigPath, env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' }, + actions: [{ waitFor: 'cordis-agent ready.', send: '/exit\r' }], }) - expect(stdout).toContain('cordis-agent ready.') + expect(output).toContain('cordis-agent ready.') }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/examples/echo-agent/README.md b/examples/echo-agent/README.md deleted file mode 100644 index 55c0baf464..0000000000 --- a/examples/echo-agent/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# echo-agent - -Runnable demo: stdin chat with a scripted mock model and an echo tool. The all-mock skeleton — "swap the backend, keep the app". - -## What it shows - -This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo) app (which bundles the whole [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) spine, JSONL persistence, the TTY-selected `dsh-tui`/`dsh-stdio` front doors, and a pre-created `main` agent), and swaps in two example-local backends plus `hmr`: - -- `mock-llm.ts` — a mock `LlmAdapter` that streams scripted responses and calls the `echo` tool when the user types "echo ". Registered with `ctx.llm.registerAdapter(['mock-echo'], …)`. -- `echo-tool.ts` — a tool registered via `ctx.tools.register(defineTool(…))` with typed `execute` args; echoes text back uppercased. - -Swapping `mock-llm` for the real `llm-deepseek` adapter is all that separates this from `repl-agent` — the same app, a different backend. - -## Plugin files - -| File | Role | Key patterns demonstrated | -|---|---|---| -| `src/mock-llm.ts` | `LlmAdapter` registration | `ctx.llm.registerAdapter(['mock-echo'], …)`, streaming chunks with the proper `block-start`/`block-end` protocol | -| `src/echo-tool.ts` | Tool registration | `ctx.tools.register(defineTool(…))` with typed `execute` args, returning `ContentBlock[]` | -| `cordis.yml` | Leaf wiring | the two backends + `hmr` + one `@deepseek-ai/dsh-stdio-demo` entry carrying the app config | - -The spine, UI, persistence, and boot glue all live in `@deepseek-ai/dsh-stdio-demo` and the bundle it loads — this folder holds only the demo-specific mocks and the leaf wiring. - -## Run - -```sh -pnpm run demo:echo -# or: -node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/echo-agent/cordis.yml -``` - -Type a message and press Enter. "echo " triggers a tool call round-trip (the mock model requests the `echo` tool, which echoes the text uppercased, and the next model step acknowledges it). - -The session is persisted under `.sessions/` relative to the directory you launch the demo from. `pnpm run demo:echo` runs from the repo root, so the logs land in `/.sessions/cwd-/` (one `.jsonl.zstd` log per session). Clean up with: `rm -rf .sessions` diff --git a/examples/echo-agent/composition.md b/examples/echo-agent/composition.md deleted file mode 100644 index 15f8e078fb..0000000000 --- a/examples/echo-agent/composition.md +++ /dev/null @@ -1,43 +0,0 @@ - - -# Echo Agent App Composition - -The echo demo swaps in a local mock LLM and teaching echo tool, then loads the stdio app package for the shared spine and terminal front door. - -```mermaid -flowchart LR - cfg["examples/echo-agent
cordis.yml"] - plugin_echo_hmr["hmr
@cordisjs/plugin-hmr"] - cfg --> plugin_echo_hmr - plugin_echo_mock_llm["mock-llm
./src/mock-llm.ts"] - cfg --> plugin_echo_mock_llm - plugin_echo_echo_tool["echo-tool
./src/echo-tool.ts"] - cfg --> plugin_echo_echo_tool - plugin_echo_bash["bash
@deepseek-ai/dsh-bash-local"] - cfg --> plugin_echo_bash - plugin_echo_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] - cfg --> plugin_echo_fs_local - plugin_echo_stdio_agent["stdio-agent
@deepseek-ai/dsh-stdio-demo"] - cfg --> plugin_echo_stdio_agent - plugin_echo_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] - plugin_echo_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_echo_stdio_agent --> frontdoor_stdio["dsh-tui (TTY) / dsh-stdio (pipes)
pre-created main agent"] - bundle_agent_core --> spine_llm["ctx.llm"] - bundle_agent_core --> spine_sessions["ctx.sessions"] - bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] - bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] -``` - -| Plugin id | Package / module | -| --- | --- | -| `hmr` | `@cordisjs/plugin-hmr` | -| `mock-llm` | `./src/mock-llm.ts` | -| `echo-tool` | `./src/echo-tool.ts` | -| `bash` | `@deepseek-ai/dsh-bash-local` | -| `fs-local` | `@deepseek-ai/dsh-fs-local` | -| `stdio-agent` | `@deepseek-ai/dsh-stdio-demo` | - -Source config: [`examples/echo-agent/cordis.yml`](cordis.yml). - -Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/examples/echo-agent/cordis.yml b/examples/echo-agent/cordis.yml deleted file mode 100644 index 6b3d19839b..0000000000 --- a/examples/echo-agent/cordis.yml +++ /dev/null @@ -1,43 +0,0 @@ -# Stdio agent with the network-free `mock-echo` adapter and example-local `echo` -# tool. The app bundle supplies the spine; this leaf selects backends, HMR, and app config. -# No API key: the `mock-echo` adapter never touches the network. - -# Hot-module reload for the dev/demo loop (a leaf entry, not baked into -# dsh-stdio-demo — it needs `node --expose-internals`, which `demo:echo` passes). -- id: hmr - name: '@cordisjs/plugin-hmr' - config: - root: ['.'] - -# Example-local model and tool plugins resolve relative to this file. -- id: mock-llm - name: './src/mock-llm.ts' - -- id: echo-tool - name: './src/echo-tool.ts' - -# Local bash executor: agent-spine-demo ships the `tool-bash` consumer schema, so the -# leaf provides the executor it runs on (the echo demo doesn't drive bash, but -# the tool is part of the shared spine). -- id: bash - name: '@deepseek-ai/dsh-bash-local' - -# Local filesystem provider for agent-spine-demo's workspace-context loader. This -# does not expose model-facing read/write/edit tools in the echo demo. -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - -# The app pre-creates `main` on the mock model and supplies persistence plus -# TTY-selected `dsh-tui`/`dsh-stdio` front doors; readline mode also owns logging. -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' - config: - provider: mock - model: mock-echo - persona: 'You are echo-agent, a demo agent.' - welcome: 'echo-agent ready. Type a message ("echo " triggers the tool).' - persistenceRoot: './.sessions' - workspaceContext: - maxBytes: 65536 diff --git a/examples/echo-agent/package.json b/examples/echo-agent/package.json deleted file mode 100644 index 00982fa297..0000000000 --- a/examples/echo-agent/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "echo-agent-example", - "private": true, - "version": "0.0.1", - "type": "module", - "description": "Runnable demo: stdin chat with a scripted mock model + echo tool" -} diff --git a/examples/echo-agent/src/echo-tool.ts b/examples/echo-agent/src/echo-tool.ts deleted file mode 100644 index dfdcb9b001..0000000000 --- a/examples/echo-agent/src/echo-tool.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Context } from 'cordis' -import { defineTool } from '@deepseek-ai/dsh-tools' - -export const name = 'echo-tool' -export const inject = ['tools'] - -export function apply(ctx: Context) { - ctx.tools.register(defineTool({ - name: 'echo', - description: 'Echo the given text back, uppercased.', - parameters: { - text: { type: 'string', required: true }, - }, - async execute(args) { - // args is typed: { text: string } - return [{ type: 'text', text: `ECHO: ${args.text.toUpperCase()}` }] - }, - })) -} diff --git a/examples/echo-agent/src/mock-llm.ts b/examples/echo-agent/src/mock-llm.ts deleted file mode 100644 index 1f61dc4ee3..0000000000 --- a/examples/echo-agent/src/mock-llm.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Context } from 'cordis' -import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' -import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' - -/** - * Demo adapter for the `mock-echo` model. - * - * Behavior: if the last user text starts with "echo ", it calls the `echo` - * tool with the rest of the line (exercising the tool round-trip), otherwise - * it streams a canned reply quoting the input. - */ -class MockEchoAdapter extends LlmAdapter { - async * stream(options: GenerateOptions): AsyncIterable { - const lastUserText = [...options.messages].reverse() - .filter(message => message.role === 'user') - .flatMap(message => message.content) - .filter(block => block.type === 'text') - .map(block => block.text) - .find(text => !text.startsWith('<')) ?? '' - - const hasToolResult = options.messages.at(-1)?.content.some(block => block.type === 'tool-result') - - if (lastUserText.startsWith('echo ') && !hasToolResult) { - const payload = lastUserText.slice(5) - const args = JSON.stringify({ text: payload }) - yield { type: 'block-start', index: 0, blockType: 'text' } - for (const char of 'Let me echo that for you.') { - yield { type: 'text-delta', index: 0, text: char } - await new Promise(resolve => setTimeout(resolve, 2)) - } - yield { type: 'block-end', index: 0, block: { type: 'text', text: 'Let me echo that for you.' } } - yield { type: 'block-start', index: 1, blockType: 'tool-call' } - yield { type: 'tool-call-delta', index: 1, id: CallId('call-echo'), name: 'echo', argumentsDelta: args } - yield { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('call-echo'), name: 'echo', arguments: args } } - yield { type: 'usage', usage: { inputTokens: 20, outputTokens: 10 } } - yield { type: 'finish', reason: { kind: 'tool-calls' } } - return - } - - const reply = hasToolResult - ? 'The echo tool has spoken.' - : `You said: "${lastUserText}". Try "echo " to see a tool call.` - yield { type: 'block-start', index: 0, blockType: 'text' } - for (const char of reply) { - yield { type: 'text-delta', index: 0, text: char } - await new Promise(resolve => setTimeout(resolve, 2)) - } - yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } } - yield { type: 'usage', usage: { inputTokens: 20, outputTokens: reply.length } } - yield { type: 'finish', reason: { kind: 'stop' } } - } -} - -export const name = 'mock-llm' -export const inject = ['llm'] - -export function apply(ctx: Context) { - ctx.llm.registerAdapter(['mock'], new MockEchoAdapter()) -} diff --git a/examples/echo-agent/tests/echo.e2e.ts b/examples/echo-agent/tests/echo.e2e.ts deleted file mode 100644 index db0d998336..0000000000 --- a/examples/echo-agent/tests/echo.e2e.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' - -/** - * Keyless-by-nature Loader-path coverage for examples/echo-agent. The real - * tree uses its deterministic mock model, so this suite is both the boot smoke - * and the complete behavior proof for the example. - */ - -const binScript = fileURLToPath(new URL('../../../packages/examples/stdio-demo/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) - -async function runEcho(stdinLines: readonly string[]): Promise { - const { stdout } = await runLoaderSmoke({ - label: 'echo-agent', - tempDirPrefix: 'echo-smoke-', - binScript, - configPath, - tsconfigPath, - stdinLines, - }) - return stdout -} - -describe('echo-agent keyless smoke (real cordis.yml via the Loader)', () => { - it('boots, prints its welcome banner, and exits cleanly on stdin EOF', async () => { - expect(await runEcho([])).toContain('echo-agent ready.') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) - - it('runs the echo tool round-trip for an "echo …" line', async () => { - const stdout = await runEcho(['echo hello world']) - expect(stdout).toContain('[tool call] echo') - expect(stdout).toContain('[tool result] ECHO: HELLO WORLD') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) - - it('streams a direct canned reply for a non-echo line', async () => { - const stdout = await runEcho(['just chatting']) - expect(stdout).toContain('just chatting') - expect(stdout).not.toContain('[tool call]') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) -}) diff --git a/examples/headless-agent/README.md b/examples/headless-agent/README.md index 285263146f..79cc348803 100644 --- a/examples/headless-agent/README.md +++ b/examples/headless-agent/README.md @@ -8,7 +8,7 @@ Headless one-shot agent wiring: DeepSeek V4 + local bash and filesystem tools + # repo root .env (gitignored) or exported env: # DEEPSEEK_API_KEY=sk-… # DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:headless -- "fix the failing test in this workspace" +pnpm run demo:headless "fix the failing test in this workspace" pnpm run demo:headless --output-format json -- "summarize the implementation" pnpm run demo:headless --output-format stream-json -- "run the focused tests" ``` diff --git a/examples/repl-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts similarity index 100% rename from examples/repl-agent/tests/code-mode.e2e.ts rename to examples/headless-agent/tests/code-mode.e2e.ts diff --git a/examples/repl-agent/tests/coding-task.e2e.ts b/examples/headless-agent/tests/coding-task.e2e.ts similarity index 100% rename from examples/repl-agent/tests/coding-task.e2e.ts rename to examples/headless-agent/tests/coding-task.e2e.ts diff --git a/examples/repl-agent/tests/compaction.e2e.ts b/examples/headless-agent/tests/compaction.e2e.ts similarity index 100% rename from examples/repl-agent/tests/compaction.e2e.ts rename to examples/headless-agent/tests/compaction.e2e.ts diff --git a/examples/headless-agent/tests/fixtures/time-context-driver.ts b/examples/headless-agent/tests/fixtures/time-context-driver.ts new file mode 100644 index 0000000000..cac81daeec --- /dev/null +++ b/examples/headless-agent/tests/fixtures/time-context-driver.ts @@ -0,0 +1,16 @@ +#!/usr/bin/env node +/** Test driver that sends two turns through one Headless Loader composition. */ + +import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts' + +const configPath = process.argv[2] +if (configPath === undefined) throw new Error('time-context driver requires a config path') + +const ctx = await boot('time-context-e2e', resolveConfigPath(configPath, undefined)) +try { + await runOneShot(ctx, { task: 'first' }) + await runOneShot(ctx, { task: 'second' }) +} finally { + await ctx.fiber.dispose() +} diff --git a/examples/headless-agent/tests/fixtures/time-context-mock-llm.ts b/examples/headless-agent/tests/fixtures/time-context-mock-llm.ts new file mode 100644 index 0000000000..8cd3155ca7 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/time-context-mock-llm.ts @@ -0,0 +1,22 @@ +import type { Context } from 'cordis' +import { LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm' + +/** Deterministic one-step adapter for the time-context Loader fixture. */ +class TimeContextMockAdapter extends LlmAdapter { + async * stream(): AsyncIterable { + const text = 'time context sampled' + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text } + yield { type: 'block-end', index: 0, block: { type: 'text', text } } + yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +export const name = 'time-context-mock-llm' +export const inject = ['llm'] + +/** Register the test-only `time-context-mock` adapter. */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['time-context-mock'], new TimeContextMockAdapter()) +} diff --git a/examples/echo-agent/tests/fixtures/context/time-context/cordis.yml b/examples/headless-agent/tests/fixtures/time-context.cordis.yml similarity index 65% rename from examples/echo-agent/tests/fixtures/context/time-context/cordis.yml rename to examples/headless-agent/tests/fixtures/time-context.cordis.yml index e6383c3c63..91ba8a1254 100644 --- a/examples/echo-agent/tests/fixtures/context/time-context/cordis.yml +++ b/examples/headless-agent/tests/fixtures/time-context.cordis.yml @@ -1,6 +1,6 @@ # Test-only composition: keep time-context opt-in while exercising its real Loader/app path. -- id: mock-llm - name: '../../../../src/mock-llm.ts' +- id: time-context-mock-llm + name: './time-context-mock-llm.ts' - id: bash name: '@deepseek-ai/dsh-bash-local' @@ -8,13 +8,12 @@ - id: time-context name: '@deepseek-ai/dsh-time-context' -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' config: - provider: mock - model: mock-echo + provider: time-context-mock + model: time-context-mock persona: 'Test the time-context plugin.' - welcome: 'time-context e2e ready.' persistenceRoot: './.sessions' persistenceCompression: 'none' workspaceContext: false diff --git a/examples/repl-agent/tests/full-loop.e2e.ts b/examples/headless-agent/tests/full-loop.e2e.ts similarity index 100% rename from examples/repl-agent/tests/full-loop.e2e.ts rename to examples/headless-agent/tests/full-loop.e2e.ts diff --git a/examples/repl-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts similarity index 98% rename from examples/repl-agent/tests/harness.ts rename to examples/headless-agent/tests/harness.ts index edf611e89d..ca1c59871e 100644 --- a/examples/repl-agent/tests/harness.ts +++ b/examples/headless-agent/tests/harness.ts @@ -15,7 +15,7 @@ import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' /** - * Shared harness for the repl-agent e2e suites: the full plugin stack + * Shared harness for the headless-agent e2e suites: the full plugin stack * with the real DeepSeek adapter and the real bash + todo_write tools. Lives * outside the *.e2e.ts pattern so importing it never re-registers another * file's tests. diff --git a/examples/repl-agent/tests/resume.e2e.ts b/examples/headless-agent/tests/resume.e2e.ts similarity index 100% rename from examples/repl-agent/tests/resume.e2e.ts rename to examples/headless-agent/tests/resume.e2e.ts diff --git a/examples/repl-agent/tests/todo-write.e2e.ts b/examples/headless-agent/tests/todo-write.e2e.ts similarity index 100% rename from examples/repl-agent/tests/todo-write.e2e.ts rename to examples/headless-agent/tests/todo-write.e2e.ts diff --git a/examples/package.json b/examples/package.json index 687c624536..53c392cd4c 100644 --- a/examples/package.json +++ b/examples/package.json @@ -9,6 +9,7 @@ "@cordisjs/plugin-include": "workspace:*", "@deepseek-ai/dsh-acp-demo": "workspace:*", "@deepseek-ai/dsh-agent-spine-demo": "workspace:*", + "@deepseek-ai/dsh-app-boot": "workspace:*", "@deepseek-ai/dsh-bash-local": "workspace:*", "@deepseek-ai/dsh-bash-sandbox": "workspace:*", "@deepseek-ai/dsh-cli-demo": "workspace:*", @@ -31,7 +32,7 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", - "@deepseek-ai/dsh-stdio-demo": "workspace:*", + "@deepseek-ai/dsh-tui-demo": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", diff --git a/examples/repl-agent/README.md b/examples/repl-agent/README.md deleted file mode 100644 index f89e24618c..0000000000 --- a/examples/repl-agent/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# repl-agent - -The repl-agent wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + workflows + `todo_write` + readline chat + JSONL persistence, loaded from `cordis.yml`. The sibling [`tui-agent`](../tui-agent/README.md) fixes the same agent composition to the full-screen terminal front door. - -## Run it - -```sh -# repo root .env (gitignored) or exported env: -# DEEPSEEK_API_KEY=sk-… -# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:repl -``` - -Type a coding task. The agent works through the `read`/`write`/`edit` filesystem tools for ordinary file operations and `bash` (+ the generic `task_output` / `task_list` / `task_kill` for background tasks) for shell commands, searches, and test runs, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). Both the fs tools and bash resolve relative paths against the session workspace. It can also delegate with `subagent`/`subagent_fork` and track multi-step work with `todo_write`. - -The REPL renders reasoning, tool calls/results, and the latest todo list as line-oriented output suitable for terminals and pipes. Use `pnpm run demo:tui` for the interactive Markdown/card interface. - -### Resuming a prior session - -Each run starts a fresh session by default (its event log lands under `./.sessions/`). To **continue** a previous conversation, set `RESUME_SESSION_ID` to that session's id — the `main` agent then rehydrates the persisted log instead of starting fresh, so the model sees the earlier turns as history: - -```sh -RESUME_SESSION_ID= pnpm run demo:repl -``` - -The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); unset, the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero, while readline reports any dropped queued input and allows piped EOF to finish. Unset it or choose an existing session id. - -## Code Mode - -[`code-mode.cordis.yml`](code-mode.cordis.yml) overlays the same tree with the worker-thread runtime and `tools: { mode: code }`. The model receives one `run_code` transport plus a generated TypeScript SDK for the visible tools; only program output returns to model context. Use `mode: both` to expose native calls alongside `run_code`. See the [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) for the execution contract. - -```sh -pnpm run demo:code-mode # this overlay under the REPL (default UI) -pnpm run demo:code-mode acp # the acp-agent example's same-shaped overlay -``` - -Try a task that spans several tool calls, e.g.: - -> Count the lines of every `*.md` file under docs/ and write the three largest to summary.txt. - -and watch the transcript: one `run_code` call, a program looping over tools, and a result the model curated instead of five round-trips of raw tool output. - -## What each leaf entry demonstrates - -This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads one app package, and adds product tools that are intentionally outside the shared spine. The spine (sessions, system-prompt, tools, agents, invariants, `agent-loop`) and the front-door cluster (JSONL persistence, the selected terminal channel, the pre-created `main` agent) live inside the [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo) app and the [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle it loads; the leaf wires the backends and model-facing optional tools: - -| Entry | Demonstrates | -|---|---| -| `hmr` (`@cordisjs/plugin-hmr`) | the dev/demo edit-reload loop — a **leaf** entry (not baked into the app) because it is Loader-only and needs `node --expose-internals`, which `demo:repl` passes | -| `llm-deepseek` | real `LlmAdapter` via config (`!!js process.env.…` secrets); swap one line to `@deepseek-ai/dsh-llm-pi-ai` for the library-backed twin | -| `bash` (`dsh-bash-local`) | the executor implementation — the swappable half of the bash seam. The model-facing `bash` schema (`tool-bash`) and generic `task_*` controls (`tool-tasks`) come from `dsh-agent-spine-demo`, so only the executor is a leaf choice | -| `stdio-agent` (`@deepseek-ai/dsh-stdio-demo`) | the app bundle: the agent-spine demo + JSONL persistence + the configured terminal channel + a pre-created `main` agent. This leaf fixes `ui.mode` to `readline`; `tui-agent` owns the corresponding TUI leaf | -| `token-meter`, `tool-result-prune`, `compact-basic` | replay-aware pressure, model-free oversized tool-result pruning, and LLM summary compaction. Pruning runs only after a compaction trigger qualifies and can avoid the summarization call | -| `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix | -| `tool-subagent`, `tool-subagent-fork` | two model-facing `dsh-tool-subagent` loads, each bound to a different provider and exposed under a distinct tool name (`subagent`, `subagent_fork`) | -| `workflow-workerthread`, `tool-workflow` | the worker-thread workflow engine and its model-facing `workflow` tool, with child calls routed through the spawn backend | -| `tool-todo` | the model-facing `todo_write` tool; writes the whole task list to the session log and renders as a persistent TUI plan or readline checklist | -| `fs-local`, `fs-policy`, `tool-fs` | the filesystem stack: the local `ctx.fs` provider, the read-before-write/edit policy gate (on the `fs/*` event gate), and the model-facing `read`/`write`/`edit` tools. Relative paths resolve against the session workspace | - -## End-to-end tests (`pnpm run test:e2e`, key-gated) - -- `tests/full-loop.e2e.ts` — the canary: real model runs `echo e2e-ok` through the real bash tool; asserts `tool/call`/`tool/result` session events and the final answer. -- `tests/coding-task.e2e.ts` — the swebench-style smoke: a temp dir holds `add.js` (with `a - b` where `a + b` belongs) and a failing `add.test.js`; the agent must fix the bug and verify. The test re-runs `node add.test.js` ITSELF and inspects the files — agent claims are not trusted. -- `tests/resume.e2e.ts` — durable continuity across processes: run 1 tells the real model a secret code and persists the turn to a temp JSONL root, then the whole context is disposed; run 2 is a fresh context over the same root that RESUMES the session id and asks the model to recall the code. The recall can only come from the rehydrated log. -- `tests/compaction.e2e.ts` — the compaction smoke: a real multi-step bash task runs with a deliberately tiny context window so automatic pruning or summary compaction fires mid-session. It verifies the world: a replayable surface replacement lands, summary brackets are complete when summarization is needed, the surface shrinks, and the agent still produces a correct final answer. -- `tests/todo-write.e2e.ts` — a real model drives the real `todo_write` tool and the test verifies the resulting `todo/write` session event. - -These self-skip without `DEEPSEEK_API_KEY`. `tests/code-mode.e2e.ts` is the with-key Code Mode proof — a real model, a two-tool task, asserting the wire tool list was exactly `[run_code]`, the `tool/code-dispatch` events landed under the parent call, and the curated answer came back. The keyless Loader smokes run in the default e2e gate: `tests/keyless-smoke.e2e.ts` and `tests/code-mode-keyless-smoke.e2e.ts`. diff --git a/examples/repl-agent/code-mode.cordis.yml b/examples/repl-agent/code-mode.cordis.yml deleted file mode 100644 index 8802b510ba..0000000000 --- a/examples/repl-agent/code-mode.cordis.yml +++ /dev/null @@ -1,33 +0,0 @@ -# Code Mode adds `ctx.codeRuntime` and changes the registry to one wire tool, -# `run_code`, plus a generated SDK for bash/read/write/edit/subagent/todo_write. -# `demo:code-mode` selects this overlay; the ACP example has the same UI-specific -# shape. A config patch replaces the whole app config, so unchanged base fields -# are restated; only `tools`, `welcome`, and the persona's second paragraph differ. -- id: base - name: '@cordisjs/plugin-include' - config: - path: ./cordis.yml - patches: - - id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' - config: - provider: deepseek - model: deepseek-v4-flash - resumeSessionId: !!js process.env.RESUME_SESSION_ID - persistenceRoot: './.sessions' - workspaceContext: - maxBytes: 65536 - tools: - mode: code - welcome: 'code-mode agent ready. Give it a multi-tool task.' - ui: - mode: readline - persona: | - You are a coding agent powered by the {{model}} model. - - You work by writing TypeScript programs for run_code: batch related - tool work into one program, loop and branch where it helps, and print - or return ONLY the findings that matter. - - insert: - - id: code-runtime - name: '@deepseek-ai/dsh-code-runtime-worker' diff --git a/examples/repl-agent/composition.md b/examples/repl-agent/composition.md deleted file mode 100644 index 6d298e7a0a..0000000000 --- a/examples/repl-agent/composition.md +++ /dev/null @@ -1,91 +0,0 @@ - - -# REPL Agent App Composition - -The REPL agent demo adds the real DeepSeek adapter, filesystem tools, todo_write, tool-result pruning, compaction, and both subagent transports on top of the stdio app package. - -```mermaid -flowchart LR - cfg["examples/repl-agent
cordis.yml"] - plugin_repl_hmr["hmr
@cordisjs/plugin-hmr"] - cfg --> plugin_repl_hmr - plugin_repl_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] - cfg --> plugin_repl_llm_deepseek - plugin_repl_bash["bash
@deepseek-ai/dsh-bash-local"] - cfg --> plugin_repl_bash - plugin_repl_stdio_agent["stdio-agent
@deepseek-ai/dsh-stdio-demo"] - cfg --> plugin_repl_stdio_agent - plugin_repl_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] - plugin_repl_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_repl_stdio_agent --> frontdoor_stdio["@deepseek-ai/dsh-stdio
pre-created main agent"] - bundle_agent_core --> spine_llm["ctx.llm"] - bundle_agent_core --> spine_sessions["ctx.sessions"] - bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] - bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] - plugin_repl_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] - cfg --> plugin_repl_token_meter - plugin_repl_tool_result_prune["tool-result-prune
@deepseek-ai/dsh-compact-tool-result-prune"] - cfg --> plugin_repl_tool_result_prune - plugin_repl_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] - cfg --> plugin_repl_compact_basic - plugin_repl_subagent["subagent
@deepseek-ai/dsh-subagent"] - cfg --> plugin_repl_subagent - plugin_repl_subagent_spawn["subagent-spawn
@deepseek-ai/dsh-subagent-spawn"] - cfg --> plugin_repl_subagent_spawn - plugin_repl_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] - cfg --> plugin_repl_subagent_fork - plugin_repl_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] - cfg --> plugin_repl_tool_subagent - plugin_repl_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] - cfg --> plugin_repl_tool_subagent_fork - plugin_repl_workflow_workerthread["workflow-workerthread
@deepseek-ai/dsh-workflow-workerthread"] - cfg --> plugin_repl_workflow_workerthread - plugin_repl_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"] - cfg --> plugin_repl_tool_workflow - plugin_repl_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] - cfg --> plugin_repl_tool_todo - plugin_repl_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] - cfg --> plugin_repl_fs_local - plugin_repl_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] - cfg --> plugin_repl_fs_policy - plugin_repl_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] - cfg --> plugin_repl_tool_fs - plugin_repl_tool_fs_search["tool-fs-search
@deepseek-ai/dsh-tool-fs-search"] - cfg --> plugin_repl_tool_fs_search - plugin_repl_timeout_policy["timeout-policy
@deepseek-ai/dsh-timeout-policy"] - cfg --> plugin_repl_timeout_policy - plugin_repl_spill_local["spill-local
@deepseek-ai/dsh-spill-local"] - cfg --> plugin_repl_spill_local - plugin_repl_spill_policy["spill-policy
@deepseek-ai/dsh-spill-policy"] - cfg --> plugin_repl_spill_policy -``` - -| Plugin id | Package / module | -| --- | --- | -| `hmr` | `@cordisjs/plugin-hmr` | -| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -| `bash` | `@deepseek-ai/dsh-bash-local` | -| `stdio-agent` | `@deepseek-ai/dsh-stdio-demo` | -| `token-meter` | `@deepseek-ai/dsh-token-meter` | -| `tool-result-prune` | `@deepseek-ai/dsh-compact-tool-result-prune` | -| `compact-basic` | `@deepseek-ai/dsh-compact-basic` | -| `subagent` | `@deepseek-ai/dsh-subagent` | -| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | -| `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | -| `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | -| `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | -| `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | -| `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | -| `tool-todo` | `@deepseek-ai/dsh-tool-todo` | -| `fs-local` | `@deepseek-ai/dsh-fs-local` | -| `fs-policy` | `@deepseek-ai/dsh-fs-policy` | -| `tool-fs` | `@deepseek-ai/dsh-tool-fs` | -| `tool-fs-search` | `@deepseek-ai/dsh-tool-fs-search` | -| `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` | -| `spill-local` | `@deepseek-ai/dsh-spill-local` | -| `spill-policy` | `@deepseek-ai/dsh-spill-policy` | - -Source config: [`examples/repl-agent/cordis.yml`](cordis.yml). - -Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/examples/repl-agent/cordis.yml b/examples/repl-agent/cordis.yml deleted file mode 100644 index 20a9e5c7e3..0000000000 --- a/examples/repl-agent/cordis.yml +++ /dev/null @@ -1,145 +0,0 @@ -# Readline coding REPL with swappable DeepSeek and local-bash backends. -# `dsh-stdio-demo` supplies the agent spine, workspace instructions, generic -# task controls, JSONL persistence, the line-oriented front door, and `main`. -# HMR remains a leaf because it requires Loader internals; `demo:repl` passes -# `--expose-internals`. The app bin loads the gitignored root `.env`; this file -# reads `DEEPSEEK_API_KEY` and optional `DEEPSEEK_BASE_URL` through `!!js`. - -# Hot-module reload for the dev/demo loop (needs `node --expose-internals`). -- id: hmr - name: '@cordisjs/plugin-hmr' - config: - root: ['.'] - -# The native DeepSeek adapter. -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - -# Local executor for the app bundle's bash tool. -- id: bash - name: '@deepseek-ai/dsh-bash-local' - config: - timeoutMs: 60000 - -# The app bundle pre-creates the REPL's `main` agent. -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' - config: - provider: deepseek - model: deepseek-v4-flash - # Set RESUME_SESSION_ID to continue a prior persisted session (the ids live - # under ./.sessions); unset starts a fresh session each run. - resumeSessionId: !!js process.env.RESUME_SESSION_ID - persistenceRoot: './.sessions' - workspaceContext: - maxBytes: 65536 - welcome: 'agent REPL ready. Give it a coding task.' - ui: - mode: readline - # Keep the persona to identity and behavior; tool plugins own tool guidance. - # The loop resolves {{model}} from this agent's configuration. - persona: | - You are a coding agent powered by the {{model}} model. - - Verify your work by running the code or tests. Keep answers brief and - factual. - -# Replay-aware request pressure with one service-wide context window. -- id: token-meter - name: '@deepseek-ai/dsh-token-meter' - -# Prune oversized tool output without a model call before summary compaction. -- id: tool-result-prune - name: '@deepseek-ai/dsh-compact-tool-result-prune' - -# Summarize an older range after measured pressure or a canonical provider overflow. -# Service-wide policy provides pressure, retention, and one overflow-retry default. -- id: compact-basic - name: '@deepseek-ai/dsh-compact-basic' - -# Expose fresh-child `spawn` and completed-prefix `fork` through independent -# in-process backends. Each tool instance needs a distinct `toolName`; the registry -# rejects duplicates. These leaves follow the app because it provides `ctx.agents` and `ctx.tools`. -- id: subagent - name: '@deepseek-ai/dsh-subagent' - -- id: subagent-spawn - name: '@deepseek-ai/dsh-subagent-spawn' - config: - providerName: spawn - -- id: subagent-fork - name: '@deepseek-ai/dsh-subagent-fork' - config: - providerName: fork - -- id: tool-subagent - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - maxDepth: 1 - -- id: tool-subagent-fork - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: fork - toolName: subagent_fork - maxDepth: 1 - - -# The worker-thread workflow engine fans a model-written JavaScript script's -# `agent()` calls out through the spawn backend; the adjacent tool exposes it to the model. -- id: workflow-workerthread - name: '@deepseek-ai/dsh-workflow-workerthread' - config: - provider: spawn - -- id: tool-workflow - name: '@deepseek-ai/dsh-tool-workflow' -# `todo_write` replaces the logged whole list and renders as a stdio checklist or ACP plan. -- id: tool-todo - name: '@deepseek-ai/dsh-tool-todo' - -# Policy loads before the model-facing filesystem tools so writes and edits require -# an observed file. This single-session app resolves relative paths from the process cwd. -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - -- id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - -- id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - -# Bash-backed discovery tools (glob/grep): if the local bash executor above -# can find rg, register fixed ripgrep commands — not ctx.fs. Capped results -# save the complete formatted list through the spill backend below -# (ctx.spillStore, optional). -- id: tool-fs-search - name: '@deepseek-ai/dsh-tool-fs-search' - -# The tool-call timeout enforcer: arms each declared ToolDefinition.timeoutMs -# (the search tools above declare 30s) as a deadline on exec.signal. Without -# it a declared budget is advisory and only the bash executor's own timeout -# backstop applies. -- id: timeout-policy - name: '@deepseek-ai/dsh-timeout-policy' - -# Tool-output spill stack: a local backend that saves oversized tool text under -# a private session-scoped dir, and the tools/post-execute policy that replaces -# an over-budget plain-text result with a preview + the spill locator/retrieval -# hint. A leaf pair after the app (needs ctx.tools). The policy is a no-op until -# a tool returns more than maxInlineBytes of plain text. -- id: spill-local - name: '@deepseek-ai/dsh-spill-local' - -- id: spill-policy - name: '@deepseek-ai/dsh-spill-policy' - config: - maxInlineBytes: 50000 diff --git a/examples/repl-agent/package.json b/examples/repl-agent/package.json deleted file mode 100644 index 34c7db6918..0000000000 --- a/examples/repl-agent/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "repl-agent-example", - "private": true, - "version": "0.0.1", - "type": "module", - "description": "Runnable demo: an agent REPL UI with DeepSeek V4 and coding tools" -} diff --git a/examples/repl-agent/tests/code-mode-keyless-smoke.e2e.ts b/examples/repl-agent/tests/code-mode-keyless-smoke.e2e.ts deleted file mode 100644 index dd4239a2c4..0000000000 --- a/examples/repl-agent/tests/code-mode-keyless-smoke.e2e.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' - -/** - * Keyless Loader-path smoke for the Code Mode overlay: boot the real include - * tree through stdio-agent and `code-mode.cordis.yml`, then close stdin without - * a prompt and assert the banner. No model or `run_code` turn runs. - */ - -const binScript = fileURLToPath(new URL('../../../packages/examples/stdio-demo/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url)) -const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) - -describe('code-mode overlay keyless smoke (real code-mode.cordis.yml via the Loader)', () => { - it('boots the Code Mode plugin tree, prints its banner, and exits cleanly on EOF', async () => { - const { stdout } = await runLoaderSmoke({ - label: 'code-mode overlay', - tempDirPrefix: 'code-mode-smoke-', - binScript, - configPath, - tsconfigPath, - env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' }, - }) - expect(stdout).toContain('code-mode agent ready.') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) -}) diff --git a/examples/repl-agent/tests/keyless-smoke.e2e.ts b/examples/repl-agent/tests/keyless-smoke.e2e.ts deleted file mode 100644 index 62eb43f55a..0000000000 --- a/examples/repl-agent/tests/keyless-smoke.e2e.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' - -/** - * Keyless Loader-path smoke for examples/repl-agent: boot the real example - * through the stdio-agent bin and its `cordis.yml`, then close stdin without a - * prompt and assert the banner. The dummy key satisfies adapter construction; - * immediate EOF guarantees there is no model call. - */ - -const binScript = fileURLToPath(new URL('../../../packages/examples/stdio-demo/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) - -describe('repl-agent keyless smoke (real cordis.yml via the Loader)', () => { - it('boots the full plugin tree, prints its banner, and exits cleanly on EOF', async () => { - const { stdout } = await runLoaderSmoke({ - label: 'repl-agent', - tempDirPrefix: 'repl-smoke-', - binScript, - configPath, - tsconfigPath, - env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' }, - }) - expect(stdout).toContain('agent REPL ready.') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) -}) diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index fb8b10e7f4..4af7be198f 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -1,6 +1,6 @@ # tui-agent -The full-screen terminal counterpart to the [`repl-agent`](../repl-agent/README.md) readline REPL and [`acp-agent`](../acp-agent/README.md) server. It reuses the coding agent's backends and tool composition, then fixes the shared terminal app to the `dsh-tui` front door. +The full-screen interactive coding agent: DeepSeek V4, local bash and filesystem tools, compaction, subagents, workflows, `todo_write`, timeout/spill policy, and [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo). ## Run it @@ -8,16 +8,16 @@ The full-screen terminal counterpart to the [`repl-agent`](../repl-agent/README. pnpm run demo:tui ``` -The command needs `DEEPSEEK_API_KEY` in the environment or the gitignored repository-root `.env`. Set `RESUME_SESSION_ID` to reopen a persisted conversation under `./.sessions`. +The command needs `DEEPSEEK_API_KEY` in the environment or gitignored repository-root `.env`. Set `RESUME_SESSION_ID` to reopen a persisted conversation under `./.sessions`. -The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and the latest todo list. Enter submits or steers while the agent is running; Ctrl+O expands cards, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `ask_user_question` opens a keyboard-driven overlay. +The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and the latest todo list. Enter submits or steers while the agent runs; Ctrl+O expands cards, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `ask_user_question` opens a keyboard-driven overlay. -Run `pnpm run demo:code-mode tui` for the sibling Code Mode overlay. +Run `pnpm run demo:code-mode tui` for the Code Mode overlay. ## Composition -[`cordis.yml`](cordis.yml) includes the readline repl-agent leaf so the LLM, bash, filesystem, compaction, subagent, workflow, todo, timeout, and spill choices have one owner. Its asserted patch replaces only the terminal app config and forces `ui.mode: tui`; [`code-mode.cordis.yml`](code-mode.cordis.yml) applies the same front-door patch to the repl-agent Code Mode overlay. +[`cordis.yml`](cordis.yml) owns the interactive coding composition directly. [`code-mode.cordis.yml`](code-mode.cordis.yml) includes that leaf and replaces the tool presentation mode while adding the code runtime. Non-interactive automation uses the sibling [headless-agent](../headless-agent/README.md) composition. ## Snapshot tests -`tests/snapshots//session.jsonl` supplies recorded user prompts and model chunks; sibling child logs drive subagents and workflows. The keyless suite executes those scripts through the real loop and tool implementations, then compares readable expected terminal cell/style output. Use `pnpm run test:snapshot:refresh` for presentation-only changes and `pnpm run test:snapshot:record` with a DeepSeek key when a recorded model journey changes. The implemented [TUI snapshot Agent Note](../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns the scenario matrix and the split between recorded journeys, transient package snapshots, and PTY coverage. +`tests/snapshots//session.jsonl` supplies recorded user prompts and model chunks; sibling child logs drive subagents and workflows. The keyless suite executes those scripts through the real loop and tools, then compares readable terminal cell/style output. Use `pnpm run test:snapshot:refresh` for presentation-only changes and `pnpm run test:snapshot:record` with a DeepSeek key when a recorded model journey changes. The implemented [TUI snapshot Agent Note](../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns the scenario matrix. diff --git a/examples/tui-agent/code-mode.cordis.yml b/examples/tui-agent/code-mode.cordis.yml index 75d2cea38a..45f5a7af04 100644 --- a/examples/tui-agent/code-mode.cordis.yml +++ b/examples/tui-agent/code-mode.cordis.yml @@ -1,12 +1,12 @@ -# Code Mode keeps the TUI front door while reusing the repl-agent overlay's -# worker runtime and one-tool registry composition. +# Code Mode keeps the TUI composition while adding the worker runtime and +# reducing the model-facing registry to the `run_code` transport. - id: base name: '@cordisjs/plugin-include' config: - path: ../repl-agent/code-mode.cordis.yml + path: ./cordis.yml patches: - - id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' + - id: tui-agent + name: '@deepseek-ai/dsh-tui-demo' config: provider: deepseek model: deepseek-v4-flash @@ -18,13 +18,14 @@ mode: code welcome: 'TUI Code Mode ready. Give it a multi-tool task.' ui: - mode: tui - tui: - showReasoning: true - maxToolOutputLines: 12 + showReasoning: true + maxToolOutputLines: 12 persona: | You are a coding agent powered by the {{model}} model. You work by writing TypeScript programs for run_code: batch related tool work into one program, loop and branch where it helps, and print or return ONLY the findings that matter. + - insert: + - id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index 94515c32c4..bed564ae34 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -3,25 +3,88 @@ # TUI Agent App Composition -The TUI agent reuses the repl-agent backend and tool composition while fixing the shared terminal app to the full-screen dsh-tui front door. +The TUI agent combines the real DeepSeek adapter, coding tools, compaction, subagents, and workflows with the full-screen terminal app package. ```mermaid flowchart LR cfg["examples/tui-agent
cordis.yml"] - plugin_tui_base["base
@deepseek-ai/dsh-stdio-demo"] - cfg --> plugin_tui_base - plugin_tui_base --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] - plugin_tui_base --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_tui_base --> frontdoor_stdio["@deepseek-ai/dsh-tui
pre-created main agent"] + plugin_tui_hmr["hmr
@cordisjs/plugin-hmr"] + cfg --> plugin_tui_hmr + plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] + cfg --> plugin_tui_llm_deepseek + plugin_tui_bash["bash
@deepseek-ai/dsh-bash-local"] + cfg --> plugin_tui_bash + plugin_tui_tui_agent["tui-agent
@deepseek-ai/dsh-tui-demo"] + cfg --> plugin_tui_tui_agent + plugin_tui_tui_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] + plugin_tui_tui_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] + plugin_tui_tui_agent --> frontdoor_tui["@deepseek-ai/dsh-tui
pre-created main agent"] bundle_agent_core --> spine_llm["ctx.llm"] bundle_agent_core --> spine_sessions["ctx.sessions"] bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] + plugin_tui_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] + cfg --> plugin_tui_token_meter + plugin_tui_tool_result_prune["tool-result-prune
@deepseek-ai/dsh-compact-tool-result-prune"] + cfg --> plugin_tui_tool_result_prune + plugin_tui_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] + cfg --> plugin_tui_compact_basic + plugin_tui_subagent["subagent
@deepseek-ai/dsh-subagent"] + cfg --> plugin_tui_subagent + plugin_tui_subagent_spawn["subagent-spawn
@deepseek-ai/dsh-subagent-spawn"] + cfg --> plugin_tui_subagent_spawn + plugin_tui_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] + cfg --> plugin_tui_subagent_fork + plugin_tui_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] + cfg --> plugin_tui_tool_subagent + plugin_tui_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] + cfg --> plugin_tui_tool_subagent_fork + plugin_tui_workflow_workerthread["workflow-workerthread
@deepseek-ai/dsh-workflow-workerthread"] + cfg --> plugin_tui_workflow_workerthread + plugin_tui_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"] + cfg --> plugin_tui_tool_workflow + plugin_tui_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] + cfg --> plugin_tui_tool_todo + plugin_tui_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] + cfg --> plugin_tui_fs_local + plugin_tui_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] + cfg --> plugin_tui_fs_policy + plugin_tui_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] + cfg --> plugin_tui_tool_fs + plugin_tui_tool_fs_search["tool-fs-search
@deepseek-ai/dsh-tool-fs-search"] + cfg --> plugin_tui_tool_fs_search + plugin_tui_timeout_policy["timeout-policy
@deepseek-ai/dsh-timeout-policy"] + cfg --> plugin_tui_timeout_policy + plugin_tui_spill_local["spill-local
@deepseek-ai/dsh-spill-local"] + cfg --> plugin_tui_spill_local + plugin_tui_spill_policy["spill-policy
@deepseek-ai/dsh-spill-policy"] + cfg --> plugin_tui_spill_policy ``` | Plugin id | Package / module | | --- | --- | -| `base` | `@deepseek-ai/dsh-stdio-demo` | +| `hmr` | `@cordisjs/plugin-hmr` | +| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `bash` | `@deepseek-ai/dsh-bash-local` | +| `tui-agent` | `@deepseek-ai/dsh-tui-demo` | +| `token-meter` | `@deepseek-ai/dsh-token-meter` | +| `tool-result-prune` | `@deepseek-ai/dsh-compact-tool-result-prune` | +| `compact-basic` | `@deepseek-ai/dsh-compact-basic` | +| `subagent` | `@deepseek-ai/dsh-subagent` | +| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | +| `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | +| `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | +| `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | +| `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | +| `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | +| `tool-todo` | `@deepseek-ai/dsh-tool-todo` | +| `fs-local` | `@deepseek-ai/dsh-fs-local` | +| `fs-policy` | `@deepseek-ai/dsh-fs-policy` | +| `tool-fs` | `@deepseek-ai/dsh-tool-fs` | +| `tool-fs-search` | `@deepseek-ai/dsh-tool-fs-search` | +| `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` | +| `spill-local` | `@deepseek-ai/dsh-spill-local` | +| `spill-policy` | `@deepseek-ai/dsh-spill-policy` | Source config: [`examples/tui-agent/cordis.yml`](cordis.yml). diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 515274b2a8..73b42b284a 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -1,28 +1,109 @@ -# Full-screen TUI front door over the same repl-agent composition used by the -# readline REPL. The include keeps backends and optional tools aligned; the -# patch owns only the terminal-specific app config. -- id: base - name: '@cordisjs/plugin-include' - config: - path: ../repl-agent/cordis.yml - patches: - - id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' - config: - provider: deepseek - model: deepseek-v4-flash - resumeSessionId: !!js process.env.RESUME_SESSION_ID - persistenceRoot: './.sessions' - workspaceContext: - maxBytes: 65536 - welcome: 'TUI agent ready. Give it a coding task.' - ui: - mode: tui - tui: - showReasoning: true - maxToolOutputLines: 12 - persona: | - You are a coding agent powered by the {{model}} model. +# Full-screen coding agent with swappable DeepSeek and local capability backends. +# `dsh-tui-demo` supplies the spine, workspace instructions, generic task controls, +# JSONL persistence, the TUI front door, and `main`. HMR remains a leaf because +# it requires Loader internals; `demo:tui` passes `--expose-internals`. - Verify your work by running the code or tests. Keep answers brief and - factual. +- id: hmr + name: '@cordisjs/plugin-hmr' + config: + root: ['.'] + +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + timeoutMs: 60000 + +- id: tui-agent + name: '@deepseek-ai/dsh-tui-demo' + config: + provider: deepseek + model: deepseek-v4-flash + resumeSessionId: !!js process.env.RESUME_SESSION_ID + persistenceRoot: './.sessions' + workspaceContext: + maxBytes: 65536 + welcome: 'TUI agent ready. Give it a coding task.' + ui: + showReasoning: true + maxToolOutputLines: 12 + persona: | + You are a coding agent powered by the {{model}} model. + + Verify your work by running the code or tests. Keep answers brief and + factual. + +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + +- id: tool-result-prune + name: '@deepseek-ai/dsh-compact-tool-result-prune' + +- id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn + +- id: subagent-fork + name: '@deepseek-ai/dsh-subagent-fork' + config: + providerName: fork + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + +- id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + +- id: workflow-workerthread + name: '@deepseek-ai/dsh-workflow-workerthread' + config: + provider: spawn + +- id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + +- id: timeout-policy + name: '@deepseek-ai/dsh-timeout-policy' + +- id: spill-local + name: '@deepseek-ai/dsh-spill-local' + +- id: spill-policy + name: '@deepseek-ai/dsh-spill-policy' + config: + maxInlineBytes: 50000 diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml index e40405524e..f8de00a1a6 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml @@ -12,8 +12,8 @@ config: cwd: !!js process.cwd() -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' +- id: tui-agent + name: '@deepseek-ai/dsh-tui-demo' config: provider: tui-scripted model: tui-scripted-model @@ -22,6 +22,4 @@ maxBytes: 65536 welcome: 'scripted TUI ready.' ui: - mode: tui - tui: - showReasoning: true + showReasoning: true diff --git a/examples/tui-agent/tests/pty-harness.ts b/examples/tui-agent/tests/pty-harness.ts new file mode 100644 index 0000000000..21d0b4c9d7 --- /dev/null +++ b/examples/tui-agent/tests/pty-harness.ts @@ -0,0 +1,127 @@ +import { spawn } from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' + +const PTY_DRIVER = String.raw` +import errno, json, os, pty, select, signal, sys, time +node, launch_args_json, launch_env_json, cwd, actions_json, expected_exit, timeout_seconds = sys.argv[1:] +env = os.environ.copy() +env.update(json.loads(launch_env_json)) +env.update({"COLUMNS": "100", "LINES": "30"}) +actions = json.loads(actions_json) +pid, fd = pty.fork() +if pid == 0: + os.chdir(cwd) + os.execvpe(node, [node, *json.loads(launch_args_json)], env) + +output = bytearray() +action_index = 0 +deadline = time.monotonic() + float(timeout_seconds) +status = None +while time.monotonic() < deadline: + ready, _, _ = select.select([fd], [], [], 0.05) + if ready: + try: + chunk = os.read(fd, 65536) + except OSError as error: + if error.errno != errno.EIO: + raise + chunk = b"" + if chunk: + output.extend(chunk) + while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output: + os.write(fd, actions[action_index]["send"].encode()) + action_index += 1 + waited, candidate = os.waitpid(pid, os.WNOHANG) + if waited == pid: + status = candidate + break + +if status is None: + os.kill(pid, signal.SIGKILL) + _, status = os.waitpid(pid, 0) +sys.stdout.buffer.write(output) +if action_index != len(actions): + sys.stderr.write(f"completed {action_index}/{len(actions)} PTY actions before timeout\n") + sys.exit(124) +actual_exit = os.waitstatus_to_exitcode(status) +if actual_exit != int(expected_exit): + sys.stderr.write(f"expected exit {expected_exit}, got {actual_exit}\n") + sys.exit(125) +` + +/** One terminal action sent after its marker has rendered. */ +interface TuiPtyAction { + readonly waitFor: string + readonly send: string +} + +/** Inputs for a keyless real-Loader TUI process smoke. */ +export interface TuiPtySmokeOptions { + readonly label: string + readonly tempDirPrefix: string + readonly binScript: string + readonly configPath: string + readonly tsconfigPath: string + readonly actions?: readonly TuiPtyAction[] + readonly env?: Readonly + readonly expectedExitCode?: number + readonly timeoutMs?: number +} + +/** + * Boot an example in a real pseudo-terminal, drive marker-gated input, and + * return the captured terminal bytes after the expected process exit. + * @param options - launch paths, environment, actions, and expected exit code. + * @returns complete pseudo-terminal output. + */ +export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise { + const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix)) + const timeoutMs = options.timeoutMs ?? 25_000 + try { + const launch = resolveExampleLaunch({ + srcBin: options.binScript, + configArgs: [options.configPath], + tsconfigPath: options.tsconfigPath, + exposeInternals: true, + env: { + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + ...options.env, + }, + }) + return await new Promise((resolve, reject) => { + const child = spawn('python3', [ + '-c', + PTY_DRIVER, + launch.command, + JSON.stringify(launch.args), + JSON.stringify(launch.env), + cwd, + JSON.stringify(options.actions ?? []), + String(options.expectedExitCode ?? 0), + String(timeoutMs / 1_000), + ], { stdio: ['ignore', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stdout.on('data', (chunk: string) => { stdout += chunk }) + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => { stderr += chunk }) + const timer = setTimeout(() => { + child.kill('SIGKILL') + reject(new Error(`${options.label} PTY driver did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }, timeoutMs + 5_000) + child.once('error', (error) => { clearTimeout(timer); reject(error) }) + child.once('exit', (code) => { + clearTimeout(timer) + if (code === 0) resolve(stdout) + else reject(new Error(`${options.label} PTY driver exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }) + }) + } finally { + await rm(cwd, { recursive: true, force: true }) + } +} diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 7cc8aebe32..21be35eef5 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -1,157 +1,43 @@ -import { spawn } from 'node:child_process' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -import { LOADER_SMOKE_TEST_TIMEOUT_MS, resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' +import { runTuiPtySmoke } from './pty-harness.ts' -const binScript = fileURLToPath(new URL('../../../packages/examples/stdio-demo/src/bin.ts', import.meta.url)) +const binScript = fileURLToPath(new URL('../../../packages/examples/tui-demo/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -const PTY_DRIVER = String.raw` -import errno, json, os, pty, select, signal, sys, time -node, launch_args_json, launch_env_json, cwd, resume_session_id, scenario = sys.argv[1:] -env = os.environ.copy() -env.update(json.loads(launch_env_json)) -env.update({ - "COLUMNS": "100", - "LINES": "30", -}) -if resume_session_id: - env["RESUME_SESSION_ID"] = resume_session_id -pid, fd = pty.fork() -if pid == 0: - os.chdir(cwd) - os.execvpe(node, [node, *json.loads(launch_args_json)], env) - -output = bytearray() -answered_question = False -sent_prompt = False -sent_exit = False -deadline = time.monotonic() + 25 -status = None -while time.monotonic() < deadline: - ready, _, _ = select.select([fd], [], [], 0.05) - if ready: - try: - chunk = os.read(fd, 65536) - except OSError as error: - if error.errno != errno.EIO: - raise - chunk = b"" - if chunk: - output.extend(chunk) - if scenario == "conversation" and not sent_prompt and b"scripted TUI ready." in output: - os.write(fd, b"exercise the TUI\r") - sent_prompt = True - if scenario == "conversation" and sent_prompt and not answered_question and b"How should the scripted run proceed?" in output: - os.write(fd, b"\r") - answered_question = True - if scenario == "conversation" and answered_question and not sent_exit and b"Decision received. Scripted TUI run complete." in output: - os.write(fd, b"/exit\r") - sent_exit = True - if scenario == "boot" and not sent_exit and b"TUI agent ready." in output: - os.write(fd, b"/exit\r") - sent_exit = True - waited, candidate = os.waitpid(pid, os.WNOHANG) - if waited == pid: - status = candidate - break - -if status is None: - os.kill(pid, signal.SIGKILL) - _, status = os.waitpid(pid, 0) -sys.stdout.buffer.write(output) -if scenario == "resume-failure": - if b'ui-tui: session "missing-session" failed to start:' not in output: - sys.stderr.write("TUI did not render the startup failure before timeout\n") - sys.exit(126) - if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 1: - sys.stderr.write("TUI startup failure did not exit with status 1\n") - sys.exit(127) -elif scenario == "conversation": - if not sent_prompt: - sys.stderr.write("TUI did not render the scripted welcome marker before timeout\n") - sys.exit(128) - if not answered_question: - sys.stderr.write("TUI did not render the user-question dialog before timeout\n") - sys.exit(129) - if not sent_exit: - sys.stderr.write("TUI did not finish the scripted tool round-trip before timeout\n") - sys.exit(130) - if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0: - sys.stderr.write("TUI scripted conversation did not exit cleanly\n") - sys.exit(131) -else: - if not sent_exit: - sys.stderr.write("TUI did not render its welcome marker before timeout\n") - sys.exit(124) - if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0: - sys.stderr.write("TUI child did not exit cleanly\n") - sys.exit(125) -` - -interface TuiLoaderSmokeOptions { - config?: string - resumeSessionId?: string - scenario?: 'boot' | 'conversation' | 'resume-failure' -} - -async function runTuiLoaderSmoke(options: TuiLoaderSmokeOptions = {}): Promise { - const cwd = await mkdtemp(join(tmpdir(), 'tui-agent-smoke-')) - try { - const launch = resolveExampleLaunch({ - srcBin: binScript, - configArgs: [options.config ?? configPath], - tsconfigPath, - exposeInternals: true, - env: { - DEEPSEEK_API_KEY: 'keyless-tui-no-call', - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), - }, - }) - return await new Promise((resolve, reject) => { - const child = spawn('python3', [ - '-c', - PTY_DRIVER, - launch.command, - JSON.stringify(launch.args), - JSON.stringify(launch.env), - cwd, - options.resumeSessionId ?? '', - options.scenario ?? 'boot', - ], { stdio: ['ignore', 'pipe', 'pipe'] }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { stdout += chunk }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - child.once('error', reject) - child.once('exit', (code) => { - if (code === 0) resolve(stdout) - else reject(new Error(`TUI PTY smoke exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }) - }) - } finally { - await rm(cwd, { recursive: true, force: true }) - } -} - -describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { +// The Python PTY driver imports the POSIX-only pty and termios modules. +describe.skipIf(process.platform === 'win32')('tui-agent keyless smoke (real Loader tree in a PTY)', () => { it('boots pi-tui, renders the configured banner, accepts /exit, and restores the terminal', async () => { - const output = await runTuiLoaderSmoke() + const output = await runTuiPtySmoke({ + label: 'tui-agent boot', + tempDirPrefix: 'tui-agent-smoke-', + binScript, + configPath, + tsconfigPath, + env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call' }, + actions: [{ waitFor: 'TUI agent ready.', send: '/exit\r' }], + }) expect(output).toContain('DEEPSEEK') expect(output).toContain('TUI agent ready.') expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('streams a response, answers a user-question dialog, completes the tool round-trip, and exits cleanly', async () => { - const output = await runTuiLoaderSmoke({ config: scriptedConfigPath, scenario: 'conversation' }) + const output = await runTuiPtySmoke({ + label: 'tui-agent conversation', + tempDirPrefix: 'tui-agent-conversation-', + binScript, + configPath: scriptedConfigPath, + tsconfigPath, + actions: [ + { waitFor: 'scripted TUI ready.', send: 'exercise the TUI\r' }, + { waitFor: 'How should the scripted run proceed?', send: '\r' }, + { waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' }, + ], + }) expect(output).toContain('I need one decision before I continue.') expect(output).toContain(String.raw`\x1b]2;MODEL_CONTROLLED\x07`) expect(output).toContain(String.raw`\x1b[999CMODEL_CURSOR`) @@ -159,14 +45,23 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { expect(output).not.toContain('\u001B]2;MODEL_CONTROLLED\u0007') expect(output).not.toContain('\u001B[999CMODEL_CURSOR') expect(output).not.toContain('\u009B31mMODEL_C1') - expect(output).toContain('How should the scripted run proceed?') expect(output).toContain('Safe') - expect(output).toContain('Decision received. Scripted TUI run complete.') expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('prints a config-resume failure and exits instead of leaving a blank terminal', async () => { - const output = await runTuiLoaderSmoke({ resumeSessionId: 'missing-session', scenario: 'resume-failure' }) + const output = await runTuiPtySmoke({ + label: 'tui-agent resume failure', + tempDirPrefix: 'tui-agent-resume-', + binScript, + configPath, + tsconfigPath, + env: { + DEEPSEEK_API_KEY: 'keyless-tui-no-call', + RESUME_SESSION_ID: 'missing-session', + }, + expectedExitCode: 1, + }) expect(output).toContain('ui-tui: session "missing-session" failed to start:') }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/knip.json b/knip.json index 294220433e..9d8fa03e08 100644 --- a/knip.json +++ b/knip.json @@ -9,8 +9,9 @@ }, "examples": { "entry": [ - "echo-agent/src/*.ts", "headless-agent/tests/fixtures/cli-mock-llm.ts", + "headless-agent/tests/fixtures/time-context-driver.ts", + "headless-agent/tests/fixtures/time-context-mock-llm.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "*/tests/**/*.e2e.ts", "*/tests/**/*.snapshot.ts" @@ -115,18 +116,14 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, - "packages/examples/stdio-demo": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "packages/examples/tui-demo": { + "entry": ["tests/**/*.spec.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/examples/cli-demo": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, - "packages/ui/stdio": { - "entry": ["tests/**/*.spec.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] - }, "packages/ui/tui": { "entry": ["tests/**/*.spec.ts", "tests/**/*.snapshot.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/package.json b/package.json index da50a55498..63025c4824 100644 --- a/package.json +++ b/package.json @@ -80,12 +80,10 @@ "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-events && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-package-readme-model-experience && pnpm run verify-mermaid && pnpm run verify-agent-note-classification && pnpm run verify-agent-note-format && pnpm run verify-type-equiv && pnpm run verify-translation-prompt && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-package-readme-limitations && pnpm run docs:check", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", - "demo:echo": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/echo-agent/cordis.yml", - "demo:repl": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/repl-agent/cordis.yml", "demo:headless": "node --expose-internals --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", - "demo:tui": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/tui-agent/cordis.yml", + "demo:tui": "node --expose-internals --import tsx packages/examples/tui-demo/src/bin.ts examples/tui-agent/cordis.yml", "demo:code-mode": "node scripts/demo-code-mode.mjs", - "demo:cordis": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/cordis-agent/cordis.yml", + "demo:cordis": "node --expose-internals --import tsx packages/examples/tui-demo/src/bin.ts examples/cordis-agent/cordis.yml", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", "postinstall": "node scripts/install-lefthook.mjs" }, diff --git a/packages/README.md b/packages/README.md index 9c6a979d61..d6f1b3355b 100644 --- a/packages/README.md +++ b/packages/README.md @@ -31,7 +31,7 @@ Packages live at `packages///`; groups are containers, while names r | [`session-query/`](session-query/README.md) | Session retrieval: logical corpus, bounded reads, lineage, and event relationships | Product — stable surface | | [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface | -| [`examples/`](examples/README.md) | Demo bundles (agent-spine + stdio/one-shot CLI/ACP/JSON-RPC bins) the leaves load | Support — example infra | +| [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/one-shot CLI/ACP/JSON-RPC bins) the leaves load | Support — example infra | | [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free | diff --git a/packages/context/time-context/tests/time-context.e2e.ts b/packages/context/time-context/tests/time-context.e2e.ts index f14981ea36..2a0c06fe51 100644 --- a/packages/context/time-context/tests/time-context.e2e.ts +++ b/packages/context/time-context/tests/time-context.e2e.ts @@ -1,34 +1,21 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' +import { readFile, readdir } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { type SessionEvent } from '@deepseek-ai/dsh-session' -import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' // Keep the Loader config under examples so both modes exercise the same deployable // topology: local fixture source plus bare plugins owned by the examples workspace. -const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url)) +const driver = fileURLToPath(new URL( + '../../../../examples/headless-agent/tests/fixtures/time-context-driver.ts', + import.meta.url, +)) const configPath = fileURLToPath(new URL( - '../../../../examples/echo-agent/tests/fixtures/context/time-context/cordis.yml', + '../../../../examples/headless-agent/tests/fixtures/time-context.cordis.yml', import.meta.url, )) const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) -const PROCESS_TIMEOUT_MS = 30_000 -const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 -const FIRST_REPLY = '[main turn 1] You said: "Time sampled while preparing turn 1, step 1:' -const SECOND_REPLY = '[main turn 2] You said: "Time sampled while preparing turn 2, step 1:' - -let child: ChildProcessWithoutNullStreams | undefined -let workdir: string | undefined - -afterEach(async () => { - if (child !== undefined && child.exitCode === null) child.kill('SIGKILL') - child = undefined - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) - workdir = undefined -}) async function jsonlFiles(dir: string): Promise { const entries = await readdir(dir, { withFileTypes: true }) @@ -40,68 +27,25 @@ async function jsonlFiles(dir: string): Promise { return paths.flat() } -async function runTwoTurns(): Promise<{ stdout: string; stderr: string }> { - workdir = await mkdtemp(join(tmpdir(), 'time-context-e2e-')) - const cwd = workdir - return new Promise((resolve, reject) => { - const launch = resolveExampleLaunch({ - srcBin: binScript, - configArgs: [configPath], +describe('time-context through a real headless cordis.yml', () => { + it('uses the process zone and persists one ordered context event per request', async () => { + let events: SessionEvent[] = [] + const { stderr } = await runLoaderSmoke({ + label: 'time-context headless smoke', + tempDirPrefix: 'time-context-e2e-', + binScript: driver, + libBinScript: driver, + configPath, tsconfigPath: repoTsconfig, - exposeInternals: true, - env: { - TZ: 'Asia/Shanghai', - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), + env: { TZ: 'Asia/Shanghai' }, + inspect: async (cwd) => { + const logs = await jsonlFiles(join(cwd, '.sessions')) + expect(logs).toHaveLength(1) + const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') + events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) }, }) - const proc = spawn(launch.command, launch.args, { - cwd, - env: { ...process.env, ...launch.env }, - stdio: ['pipe', 'pipe', 'pipe'], - }) - child = proc - let stdout = '' - let stderr = '' - let sentSecond = false - proc.stdout.setEncoding('utf8') - proc.stdout.on('data', (chunk: string) => { - stdout += chunk - if (!sentSecond && stdout.includes(FIRST_REPLY) && stdout.includes('Try "echo " to see a tool call.\n> ')) { - sentSecond = true - proc.stdin.end('second\n') - } - }) - proc.stderr.setEncoding('utf8') - proc.stderr.on('data', (chunk: string) => { stderr += chunk }) - - const timer = setTimeout(() => { - proc.kill('SIGKILL') - reject(new Error(`time-context e2e did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, PROCESS_TIMEOUT_MS) - - proc.on('exit', (code) => { - clearTimeout(timer) - if (code === 0) resolve({ stdout, stderr }) - else reject(new Error(`time-context e2e exited ${code}. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }) - proc.on('error', (error) => { clearTimeout(timer); reject(error) }) - proc.stdin.write('first\n') - }) -} - -describe('time-context through a real cordis.yml and stdio process', () => { - it('uses the process zone and persists one ordered context event per request', async () => { - const { stdout, stderr } = await runTwoTurns() expect(stderr).not.toContain('UNHANDLED') - expect(stdout).toContain('time-context e2e ready.') - expect(stdout).toContain(FIRST_REPLY) - expect(stdout).toContain(SECOND_REPLY) - - const logs = await jsonlFiles(join(workdir as string, '.sessions')) - expect(logs).toHaveLength(1) - const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') - const events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2) const contexts = events.filter(event => event.type === 'context/message') @@ -127,5 +71,5 @@ describe('time-context through a real cordis.yml and stdio process', () => { const headers = events.filter(event => event.type === 'request/header') expect(JSON.stringify(headers)).not.toContain('Time sampled while preparing') - }, TEST_TIMEOUT_MS) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/packages/cordis/tool-cordis/src/sandbox.ts b/packages/cordis/tool-cordis/src/sandbox.ts index 99a68b062f..995881902e 100644 --- a/packages/cordis/tool-cordis/src/sandbox.ts +++ b/packages/cordis/tool-cordis/src/sandbox.ts @@ -15,7 +15,7 @@ import { sandboxDefineTool, sandboxRegisterTool } from './guard.ts' * A write-through console for one sandbox, tagging every line with the mount * id. Write-through (host stdout/stderr), NOT buffered into the tool result: * a mounted listener fires long after the mount call returned, and its output - * must land somewhere the user can see — for the stdio demo, the terminal. + * must land somewhere the user can see — for a terminal front door, the host terminal. */ function taggedConsole(id: string): Record<'log' | 'info' | 'warn' | 'error' | 'debug', (...args: unknown[]) => void> { const tag = `[cordis:${id}]` diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 5f9bf3dd07..2103dd6831 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -47,9 +47,9 @@ describe('config-driven session id', () => { it('accepts one exact fresh id and rejects it alongside a resume id', async () => { const exact = await makeCoreContext() await exact.plugin(AgentLoop, { - agents: [{ id: 'main', sessionId: SessionId('stdio-exact'), model: 'mock' }], + agents: [{ id: 'main', sessionId: SessionId('config-exact'), model: 'mock' }], }) - expect(exact.agents.get(SessionId('stdio-exact'))?.session.id).toBe('stdio-exact') + expect(exact.agents.get(SessionId('config-exact'))?.session.id).toBe('config-exact') await exact.fiber.dispose() const conflicting = await makeCoreContext() @@ -89,13 +89,13 @@ describe('config-driven session id', () => { const ctx = await makeCoreContext() await ctx.plugin(SessionPersistenceJsonl, { root }) ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first'), textResponse('second')])) - const config = { agents: [{ id: 'main', sessionId: SessionId('stdio-exact-reload'), model: 'mock' }] } + const config = { agents: [{ id: 'main', sessionId: SessionId('config-exact-reload'), model: 'mock' }] } const firstLoop = await ctx.plugin(AgentLoop, config) let first: Agent | undefined for (let i = 0; i < 50 && first === undefined; i++) { await new Promise(resolve => setTimeout(resolve, 5)) - first = ctx.agents.get(SessionId('stdio-exact-reload')) + first = ctx.agents.get(SessionId('config-exact-reload')) } expect(first).toBeDefined() first!.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) @@ -106,14 +106,14 @@ describe('config-driven session id', () => { let second: Agent | undefined for (let i = 0; i < 50 && second === undefined; i++) { await new Promise(resolve => setTimeout(resolve, 5)) - second = ctx.agents.get(SessionId('stdio-exact-reload')) + second = ctx.agents.get(SessionId('config-exact-reload')) } expect(second).toBeDefined() expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me') second!.send([{ type: 'text', text: 'continue' }], { source: { kind: 'user' } }) await waitForIdle(ctx, second!) await ctx.sessions.flush(second!.session) - const loaded = await ctx.sessionPersistence.load(SessionId('stdio-exact-reload')) + const loaded = await ctx.sessionPersistence.load(SessionId('config-exact-reload')) expect(loaded.events.filter(event => event.type === 'turn/start')).toHaveLength(2) await secondLoop.dispose() @@ -125,7 +125,7 @@ describe('config-driven session id', () => { dirs.push(root) const ctx = await makeCoreContext() await ctx.plugin(SessionPersistenceJsonl, { root }) - const sessionId = SessionId('stdio-exact-overlap') + const sessionId = SessionId('config-exact-overlap') const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] } const firstLoop = await ctx.plugin(AgentLoop, config) await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined() @@ -169,7 +169,7 @@ describe('config-driven session id', () => { dirs.push(root) const ctx = await makeCoreContext() await ctx.plugin(SessionPersistenceJsonl, { root }) - const sessionId = SessionId('stdio-exact-cancel') + const sessionId = SessionId('config-exact-cancel') const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] } const firstLoop = await ctx.plugin(AgentLoop, config) await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined() @@ -213,20 +213,20 @@ describe('config-driven session id', () => { const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) await ctx.plugin(AgentLoop, { - agents: [{ id: 'main', sessionId: SessionId('stdio-exact-failure'), model: 'mock' }], + agents: [{ id: 'main', sessionId: SessionId('config-exact-failure'), model: 'mock' }], }) await expect.poll(() => warn).toHaveBeenCalledWith(expect.stringContaining( - 'config-driven restore of "stdio-exact-failure" failed: persistence index failed', + 'config-driven restore of "config-exact-failure" failed: persistence index failed', )) - expect(failures).toEqual([{ sessionId: SessionId('stdio-exact-failure'), error: failure }]) + expect(failures).toEqual([{ sessionId: SessionId('config-exact-failure'), error: failure }]) expect(warn).toHaveBeenCalledWith( 'agent "main": config-start-failed listener threw: failure observer failed', ) await expect.poll(() => warn).toHaveBeenCalledWith( 'agent "main": config-start-failed listener rejected: async failure observer failed', ) - expect(ctx.agents.get(SessionId('stdio-exact-failure'))).toBeUndefined() + expect(ctx.agents.get(SessionId('config-exact-failure'))).toBeUndefined() warn.mockRestore() await ctx.fiber.dispose() }) @@ -251,12 +251,12 @@ describe('config-driven session id', () => { const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) await ctx.plugin(AgentLoop, { - agents: [{ id: 'main', sessionId: SessionId('stdio-exact-unrenderable'), model: 'mock' }], + agents: [{ id: 'main', sessionId: SessionId('config-exact-unrenderable'), model: 'mock' }], }) await expect.poll(() => failures).toEqual([unrenderable]) expect(warn).toHaveBeenCalledWith( - 'agent "main": config-driven restore of "stdio-exact-unrenderable" failed: ', + 'agent "main": config-driven restore of "config-exact-unrenderable" failed: ', ) expect(warn).toHaveBeenCalledWith( 'agent "main": config-start-failed listener threw: ', @@ -281,7 +281,7 @@ describe('config-driven session id', () => { ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) }) const loop = await ctx.plugin(AgentLoop, { - agents: [{ id: 'main', sessionId: SessionId('stdio-exact-dispose'), model: 'mock' }], + agents: [{ id: 'main', sessionId: SessionId('config-exact-dispose'), model: 'mock' }], }) let disposed = false const disposal = loop.dispose().then(() => { disposed = true }) @@ -291,7 +291,7 @@ describe('config-driven session id', () => { if (outcome === 'resolve') listing.resolve([]) else listing.reject(new Error('startup cancelled by teardown')) await disposal - expect(ctx.agents.get(SessionId('stdio-exact-dispose'))).toBeUndefined() + expect(ctx.agents.get(SessionId('config-exact-dispose'))).toBeUndefined() expect(failures).toEqual([]) expect(warn).not.toHaveBeenCalled() warn.mockRestore() diff --git a/packages/examples/README.md b/packages/examples/README.md index 5703039d94..f13d15a8ea 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -5,12 +5,12 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling | Package | npm name | Role | |---|---|---| | `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + workspace-context + `tool-skill` + `agent-loop`) | -| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal chat app: the spine + JSONL persistence + TTY-selected `dsh-tui`/`dsh-stdio` front door + a pre-created `main` agent, with a boot `bin` | +| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app: the spine + JSONL persistence + `dsh-tui` + a pre-created `main` agent, with a boot `bin` | | `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output | | `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` | | `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client | -`agent-spine-demo` is the shared bundle; `stdio-demo`, `cli-demo`, and `acp-demo` compose it with terminal, headless one-shot, and ACP front doors and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches. +`agent-spine-demo` is the shared bundle; `tui-demo`, `cli-demo`, and `acp-demo` compose it with full-screen terminal, headless one-shot, and ACP front doors and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches. These are **not** product API. The spine pieces they bundle live in [`core/`](../core/README.md), the bridges/channels/boot-glue in [`ui/`](../ui/README.md), and the swappable backends (LLM adapter, bash executor) in their capability groups; a demo bundle just picks one concrete composition of them. Swap or fork one freely. diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index 2097348ead..82347c1d7e 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -2,7 +2,7 @@ The **ACP server app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md)) with the front-door cluster an [Agent Client Protocol](../../ui/acp/README.md) server needs, and a `bin` that boots a leaf `cordis.yml` speaking ACP JSON-RPC on stdio. -It is the structured counterpart to [`@deepseek-ai/dsh-stdio-demo`](../stdio-demo/README.md): both consume the same spine, but this one bakes in the OPPOSITE front-door cluster. +It is the structured counterpart to [`@deepseek-ai/dsh-tui-demo`](../tui-demo/README.md): both consume the same spine, but ACP creates sessions from its client and reserves stdout for its wire protocol. ## What it bakes in — and what it deliberately omits @@ -19,7 +19,7 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it | ~~console logger~~ | **omitted** — it writes to stdout and would corrupt the protocol frames ([the stdout-purity footgun](../../ui/acp/README.md)) | | ~~`hmr`~~ | **omitted** — the editor owns the subprocess | -Because the package wires no logger entry, an ACP leaf has **nothing to get wrong by default**: it only picks backends, so the common mistake — copying a console-logger entry from the stdio config — has no place here. (A leaf author technically *can* still add `@cordisjs/plugin-logger-console` as a sibling entry; the package can't forbid that. So the rule stands: never add a stdout logger to an ACP leaf — stdout is the JSON-RPC channel. Use a stderr exporter if you need logs.) +Because the package wires no logger entry, an ACP leaf has **nothing to get wrong by default**: it only picks backends. A leaf author can still add `@cordisjs/plugin-logger-console` as a sibling entry, so the rule remains: never add a stdout logger to an ACP leaf; use a stderr exporter instead. ## Config diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index 9e658ba37c..dc9dfaa6fd 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -12,8 +12,8 @@ import * as acpAgent from '../src/index.ts' /** * In-process unit coverage for the @deepseek-ai/dsh-acp-demo composition: * mounting it brings up the agent-spine-demo spine + JSONL persistence + the ACP - * bridge in one `ctx.plugin`. Unlike the stdio app, this one loads NO - * Loader-only plugin (no hmr), so it mounts in a plain Context. + * bridge in one `ctx.plugin`. It loads no Loader-only plugin (no hmr), so it + * mounts in a plain Context. * * The REAL Loader-path guard (export shape via `unwrapExports`, the headline * ACP operations end-to-end) is the keyless bin smoke in `load-path.e2e.ts`; diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 741e23c7e8..2703cb8e81 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -39,7 +39,7 @@ The spine is everything COMMON to every front door. The swappable and front-door - **the LLM adapter** — the bundle ships the abstract `llm` service; the leaf registers a concrete adapter on `ctx.llm` (`llm-deepseek`, `llm-pi-ai`, `llm-replay`). - **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl). - **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings. -- **presentation + per-app infra** — the terminal (`dsh-tui` / `dsh-stdio`) or ACP front door and `hmr`. These form the coupled front-door cluster that the app packages ([`dsh-stdio-demo`](../stdio-demo/README.md), [`dsh-acp-demo`](../acp-demo/README.md)) bake in. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside. +- **presentation + per-app infra** — the terminal TUI or ACP front door and `hmr`. These form the coupled front-door cluster that the app packages ([`dsh-tui-demo`](../tui-demo/README.md), [`dsh-acp-demo`](../acp-demo/README.md)) bake in. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside. This is the [interface/implementation/consumer seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) raised to the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door. @@ -51,7 +51,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service without exposing task-control tools. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. +The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules. diff --git a/packages/examples/cli-demo/README.md b/packages/examples/cli-demo/README.md index 843473d9d4..958dd4c3bb 100644 --- a/packages/examples/cli-demo/README.md +++ b/packages/examples/cli-demo/README.md @@ -1,8 +1,8 @@ # @deepseek-ai/dsh-cli-demo -Headless one-shot app and bin for running one agent task without a readline or editor client. It composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, and exactly one fresh top-level agent. The bin submits the task, waits for its durable turn ending, renders the selected output, disposes to quiescence, and exits. +Headless one-shot app and bin for running one agent task without an interactive UI or editor client. It composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, and exactly one fresh top-level agent. The bin submits the task, waits for its durable turn ending, renders the selected output, disposes to quiescence, and exits. -The package mounts no console logger, readline UI, user-interaction service, or `ask_user_question` tool. Stdout is reserved for the selected output format; diagnostics use stderr. +The package mounts no console logger, interactive UI, user-interaction service, or `ask_user_question` tool. Stdout is reserved for the selected output format; diagnostics use stderr. ## Config @@ -33,7 +33,7 @@ dsh-cli-demo [--config path] [--output-format text|json|stream-json] The root headless-agent example supplies its leaf: ```sh -pnpm run demo:headless -- "inspect the failing test and fix it" +pnpm run demo:headless "inspect the failing test and fix it" ``` Loader configs with bare package specifiers require `node --expose-internals` or the Loader's optional native fallback. The root command supplies the Node flag. diff --git a/packages/examples/stdio-demo/README.md b/packages/examples/stdio-demo/README.md deleted file mode 100644 index b6f6c89ec5..0000000000 --- a/packages/examples/stdio-demo/README.md +++ /dev/null @@ -1,113 +0,0 @@ -# @deepseek-ai/dsh-stdio-demo - -The **terminal chat app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md)) with JSONL persistence, human interaction, a pre-created `main` agent, and a TTY-selected pi-tui/readline front door. Its `bin` boots a leaf `cordis.yml`. - -It is the terminal counterpart to [`@deepseek-ai/dsh-acp-demo`](../acp-demo/README.md): both consume the same spine, while ACP reserves stdout for JSON-RPC and creates sessions from the client. - -## What it bakes in - -A terminal chat always wants the same cluster, so the package owns it rather than trusting each leaf to re-wire it: - -| Plugin | Why it is here | -|---|---| -| `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating a `main` agent from this app's provider/model pair with `process.cwd()` as the fresh session cwd and carrying its `persona` | -| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` | -| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools | -| `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool | -| `@cordisjs/plugin-logger-console` | readline diagnostics for non-TTY operation; omitted from the fullscreen TUI path | -| `@deepseek-ai/dsh-stdio` | the line-oriented channel for pipes and automation, bound to the exact app-owned agent/session identity | -| `@deepseek-ai/dsh-tui` | the fullscreen interactive channel for TTY pairs, bound to the same exact identity | - -`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`. - -The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapter (`llm-deepseek` for the real model, or the mock `mock-llm` for a demo) and a bash executor (`bash-local`) — `hmr`, plus this app's [`Config`](#config). The whole plugin tree a run loads is therefore: this app's cluster, the spine inside `agent-spine-demo`, `hmr`, and the two leaf backends. - -## Config - -| Key | Default | Routed to | -|---|---|---| -| `provider` | (required) | the pre-created `main` agent's registered provider route | -| `model` | (required) | the pre-created `main` agent's model | -| `maxParallelToolCalls` | agent-loop default | positive-integer concurrent tool-call cap shared by the bundled loop's agents; `1` is serial | -| `persona` | — | the deployment persona template (may reference `{{provider}}`/`{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` | -| `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` | -| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery | -| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` | -| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` | -| `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` | -| `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` | -| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | -| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) | -| `welcome` | `ready.` | terminal banner / TUI subtitle | -| `ui` | `{ mode: 'auto' }` | terminal mode (`auto` / `readline` / `tui`) and nested TUI presentation config | -| `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) | - -Fresh terminal sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-` agent/session id, so durable restarts cannot collide. The app passes that exact opaque id to the config-created agent and selected UI before agent-core starts; this lets either front door observe `agent-loop/config-start-failed`, and an AgentLoop-only reload restores materialized history under the same id. Readline buffers startup input until `agent/session-start`; the TUI waits to enter fullscreen until the matching root appears. A resumed run binds both components to the exact `resumeSessionId` and keeps the persisted cwd. - -## The bin - -`dsh-stdio-demo [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config and awaits the whole plugin tree before returning. Run it under `node --expose-internals`, or install the Loader's optional `node-addon-require-builtin` fallback, so the Loader can resolve the config's bare plugin specifiers (`@deepseek-ai/dsh-*`, npm packages). The `demo:echo` / `demo:repl` scripts use `--expose-internals`. - -## Example leaf `cordis.yml` - -```yaml -# A REPL agent demo: hmr + the DeepSeek adapter + local bash, then this app. -- id: hmr - name: '@cordisjs/plugin-hmr' - config: - root: ['.'] -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY -- id: bash - name: '@deepseek-ai/dsh-bash-local' - config: - timeoutMs: 60000 -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' - config: - provider: deepseek - model: deepseek-v4-flash - persona: 'You are a coding assistant powered by the {{model}} model.' - ui: - mode: auto -``` - -Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo — "swap the backend, keep the app". - -## Model Experience - -### Composed terminal agent request - -#### What the model sees - -Through `dsh-agent-spine-demo`, the `main` agent receives the harness identity, configured persona, skill catalog, and visible tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each terminal submission becomes a user message; submissions made while the agent runs steer the active turn. - -#### Token effect - -Child prompt and schema costs repeat per request; user input and tool history grow until compaction. Terminal banners, logger output, cards, and rendered transcripts add zero model tokens. - -#### KV Cache effect - -User and tool history is append-only while the composed prompt, schemas, child model route, and session prefix remain fixed. A composition change or compaction may invalidate reuse from its first changed token; terminal rendering has no cache effect. - -### Human-answer result - -#### What the model sees - -Through `dsh-tool-ask-user`, successful terminal answers use that package's exact compact JSON shape. Interruption becomes exactly `Error: ask_user_question was interrupted before the user answered`; a closed stdin becomes `Error: ask_user_question cannot be answered because stdin is closed`. - -#### Token effect - -Only a completed or failed tool call adds retained result tokens; prompts printed while waiting are terminal-only. - -#### KV Cache effect - -Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. - -## Known Limitations and Deferred Work - -- **One pre-created `main` agent drives the selected terminal UI** — there is no multi-session or concurrent-agent surface in this app; a run is one conversation. -- **The front-door cluster is fixed in code** — the JSONL persistence backend and the ask-user tooling are baked; a different composition is a leaf-level sibling entry or another app package. -- **The question tool is not an approval answerer** — this app mounts `user-interaction` and `ask_user_question`, but not `ctx.approval`; a `tools/pre-execute` `ask` therefore fails closed unless the leaf composes an approval service and terminal answerer. diff --git a/packages/examples/stdio-demo/src/index.ts b/packages/examples/stdio-demo/src/index.ts deleted file mode 100644 index a91ac95da6..0000000000 --- a/packages/examples/stdio-demo/src/index.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * The stdio chat app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}) plus the - * coupled front-door cluster a terminal chat needs — TTY-selected pi-tui/readline - * presentation, JSONL session persistence, the user-interaction seam with its - * `ask_user_question` tool, and one pre-created agent whose exact shared - * agent/session identity the selected UI drives under its `main` display label. - * Swappable adapters, executors, optional tools, and HMR stay in the leaf. This - * Loader plugin intentionally exposes named exports only; a default export - * would hide its `Config` schema (see docs/postmortem/0001). - * @module @deepseek-ai/dsh-stdio-demo - */ - -import type { Context } from 'cordis' -import { randomUUID } from 'node:crypto' -import ConsoleExporter from '@cordisjs/plugin-logger-console' -import z from 'schemastery' -import { SessionId } from '@deepseek-ai/dsh-session' -import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' -import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' -import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' -import SessionPersistenceJsonl, { - JsonlCompressionSchema, - type JsonlCompression, -} from '@deepseek-ai/dsh-session-persistence-jsonl' -import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' -import * as uiStdio from '@deepseek-ai/dsh-stdio' -import * as uiTui from '@deepseek-ai/dsh-tui' - -export const name = 'stdio-demo' -const DEFAULT_PERSISTENCE_ROOT = './.sessions' -const DEFAULT_WELCOME = 'ready.' - -/** Terminal front door selected by the app bundle. */ -export type TerminalMode = 'auto' | 'readline' | 'tui' - -/** App-level terminal selection with nested TUI presentation settings. */ -export interface UiConfig { - /** Select a concrete front door or infer it from the process streams. */ - mode?: TerminalMode - /** Settings forwarded only when the pi-tui front door is selected. */ - tui?: uiTui.TuiConfig -} - -const terminalModeSchema = z.union(['auto', 'readline', 'tui'] as const).default('auto') - -/** Schemastery schema for app-level terminal selection. */ -export const UiConfigSchema: z = z.object({ - mode: terminalModeSchema, - tui: uiTui.TuiConfigSchema, -}) - -/** - * Resolve the app's terminal front door. - * @param config - app-level terminal selection. - * @param isTTY - whether both process streams are interactive TTYs. - * @returns the concrete UI package to mount. - */ -export function resolveTerminalMode(config: UiConfig | undefined, isTTY: boolean): Exclude { - const mode = config?.mode ?? 'auto' - if (mode === 'auto') return isTTY ? 'tui' : 'readline' - if (mode === 'tui' && !isTTY) { - throw new Error('stdio-demo: TUI mode requires both stdin and stdout to be TTYs; use mode "readline" for pipes') - } - return mode -} - -/** - * App config: the swappable per-demo values, each routed to where the app wires - * it. `provider`/`model`/`resumeSessionId` configure the pre-created `main` agent (through - * {@link @deepseek-ai/dsh-agent-spine-demo}'s forwarded `agents` list); `persona` is - * the deployment persona (forwarded to the system-prompt plugin); `toolOrder` - * is the explicit model-facing tool order (forwarded to the system-prompt plugin); - * fresh sessions use `process.cwd()` as their workspace cwd; resumed sessions - * keep their persisted cwd. `persistenceRoot` is the JSONL backend's directory; - * `welcome` is the UI banner and `ui` configures terminal mode/presentation. - */ -export interface Config { - /** Provider route for the `main` agent. */ - provider: string - /** Model name for the `main` agent (must have a registered adapter). */ - model: string - /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ - maxParallelToolCalls?: number - /** Deployment persona (the system-prompt plugin's `persona` config). */ - persona?: string - /** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */ - toolOrder?: string[] - /** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */ - tools?: ToolsConfig - /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ - dshHome?: string - /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ - persistenceRoot?: string - /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ - persistenceCompression?: JsonlCompression - /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */ - welcome?: string - /** Terminal front-door selection and pi-tui presentation settings. */ - ui?: UiConfig - /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */ - skills?: agentCore.SkillConfig - /** Model-facing bash tool config forwarded through agent-core. */ - toolBash?: NonNullable - /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ - toolTasks?: NonNullable - /** - * If set, the pre-created agent RESUMES this persisted session id instead of - * starting fresh. Sourced from an env var in the leaf `cordis.yml` - * (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`). - */ - resumeSessionId?: string - /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ - workspaceContext: agentCore.Config['workspaceContext'] -} - -export const Config: z = z.object({ - provider: z.string().required(), - model: z.string().required(), - maxParallelToolCalls: z.number().step(1).min(1), - persona: z.string(), - // The array default is forced to undefined: ABSENT means "lexicographic - // order" (the owning dsh-system-prompt schema does the same), while - // schemastery's native [] default would read as an invalid configured list. - toolOrder: z.array(z.string()).default(undefined as unknown as string[]), - tools: ToolRegistry.Config, - dshHome: z.string(), - persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT), - persistenceCompression: JsonlCompressionSchema, - welcome: z.string().default(DEFAULT_WELCOME), - ui: UiConfigSchema, - skills: agentCore.SkillConfigSchema, - toolBash: agentCore.ToolBashConfigSchema, - toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), - resumeSessionId: z.string(), - workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), -}) - -/** - * Compose the spine with one terminal front door. Persistence and user - * interaction mount first; the selected UI then waits on the exact session id - * and subscribes to config-start failures before agent-core starts it. Console - * logging is readline-only because fullscreen output belongs to pi-tui. The - * ask-user tool waits on the completed spine, and HMR remains a leaf concern. - * @param ctx - context receiving the app's child plugins. - * @param config - app configuration routed to the spine and front door. - * @param isTTY - whether both process streams are interactive TTYs. - */ -export function composeTerminalApp(ctx: Context, config: Config, isTTY: boolean): void { - const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId - const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`) - const mode = resolveTerminalMode(config.ui, isTTY) - if (mode === 'readline') ctx.plugin(ConsoleExporter) - ctx.plugin(SessionPersistenceJsonl, { - root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT, - ...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }), - }) - ctx.plugin(UserInteractionService) - if (mode === 'tui') { - ctx.plugin(uiTui, { - ...config.ui?.tui, - welcome: config.welcome ?? DEFAULT_WELCOME, - sessionId, - }) - } else { - ctx.plugin(uiStdio, { - welcome: config.welcome ?? DEFAULT_WELCOME, - sessionId, - }) - } - ctx.plugin(agentCore, { - ...agentCore.pickSpineConfig(config), - agents: [{ - id: SessionId('main'), - provider: config.provider, - model: config.model, - cwd: process.cwd(), - ...resumeSessionId === undefined ? { sessionId } : { resumeSessionId: sessionId }, - }], - }) - ctx.plugin(toolAskUser) -} - -/** Compose the configured terminal front door with the agent app. */ -/* v8 ignore start -- production stream capability wiring; composeTerminalApp is unit-covered, - and the repl-agent PTY smoke covers the interactive process path */ -export function apply(ctx: Context, config: Config): void { - composeTerminalApp(ctx, config, process.stdin.isTTY && process.stdout.isTTY) -} -/* v8 ignore stop */ diff --git a/packages/examples/stdio-demo/tests/built-bin.e2e.ts b/packages/examples/stdio-demo/tests/built-bin.e2e.ts deleted file mode 100644 index ddcbf46d09..0000000000 --- a/packages/examples/stdio-demo/tests/built-bin.e2e.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { spawn } from 'node:child_process' -import { cp, mkdtemp, mkdir, readdir, rm, symlink, writeFile, readFile } from 'node:fs/promises' -import { existsSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { promisify } from 'node:util' -import { zstdDecompress } from 'node:zlib' -import { afterEach, describe, expect, it } from 'vitest' - -/** - * Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer and - * require the banner plus echo round-trip. This catches built-only early-exit and config-resolution - * failures masked by tsx source smokes. It skips before build; `--expose-internals` enables Cordis - * bare-plugin loading, matching the demo command. - */ - -const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) -const stdioBin = join(repoRoot, 'packages/examples/stdio-demo/lib/bin.js') -const decompress = promisify(zstdDecompress) - -// Symlink each required workspace package by package name so plain Node resolves its built `main`, -// matching an installed dependency rather than tsconfig paths. -const dshPackages = [ - 'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/scope', 'core/system-prompt', - 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local', - 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot', - 'session-persistence/session-persistence', - 'session-persistence/session-persistence-jsonl', 'examples/stdio-demo', 'util/paths', - 'ui/stdio', 'ui/tool-ask-user', 'ui/user-interaction', -] -const vendorPackages = [ - 'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console', - 'schemastery', 'cosmokit', -] - -async function pkgName(absDir: string): Promise { - const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string } - return json.name -} - -async function installWorkspacePackageCopy(absDir: string, target: string): Promise { - await mkdir(dirname(target), { recursive: true }) - await cp(absDir, target, { - recursive: true, - filter: source => !source.split('/').includes('node_modules'), - }) -} - -/** - * Build a temporary external consumer with built workspace/vendor links and a mock-backed config. - * The optional missing-but-disabled plugin verifies load guards accept intentionally fiber-less - * entries rather than treating them as import failures. - */ -async function makeConsumer( - welcome: string, - disabledBrokenEntry = false, - extraDshPackages: string[] = [], - extraEntries: string[] = [], -): Promise { - const dir = await mkdtemp(join(tmpdir(), 'stdio-built-bin-')) - const nm = join(dir, 'node_modules') - for (const rel of [...dshPackages, ...extraDshPackages]) { - const abs = join(repoRoot, 'packages', rel) - const name = await pkgName(abs) - const target = join(nm, name) - if (extraDshPackages.includes(rel)) { - await installWorkspacePackageCopy(abs, target) - } else { - await mkdir(dirname(target), { recursive: true }) - await symlink(abs, target) - } - } - for (const v of vendorPackages) { - const abs = join(repoRoot, 'vendor', v) - const name = await pkgName(abs) - const target = join(nm, name) - await mkdir(dirname(target), { recursive: true }) - await symlink(abs, target) - } - // The example's mock model + echo tool are example-local TS plugins (Node - // 22.19+ — the engines floor — strips types natively, so plain `node` loads - // them); they import the workspace packages the symlinked node_modules now - // provides. - await cp(join(repoRoot, 'examples/echo-agent/src'), join(dir, 'src'), { recursive: true }) - await writeFile(join(dir, 'cordis.yml'), [ - '- id: mock-llm', - ' name: \'./src/mock-llm.ts\'', - '- id: echo-tool', - ' name: \'./src/echo-tool.ts\'', - '- id: bash', - ' name: \'@deepseek-ai/dsh-bash-local\'', - '- id: stdio-agent', - ' name: \'@deepseek-ai/dsh-stdio-demo\'', - ' config:', - ' provider: mock', - ' model: mock-echo', - ' persona: \'demo\'', - ' workspaceContext: false', - ` welcome: '${welcome}'`, - ...extraEntries, - ...disabledBrokenEntry - ? ['- id: off', ' name: \'./src/does-not-exist.ts\'', ' disabled: true'] - : [], - '', - ].join('\n')) - return dir -} - -/** Run the built bin in `cwd` against `configArg` with piped stdin; resolve with stdout/stderr + exit code. */ -function runBuiltBin(cwd: string, configArg: string, input: string): Promise<{ stdout: string; code: number; stderr: string }> { - return new Promise((resolve, reject) => { - // --expose-internals: the cordis Loader resolves bare plugin specifiers via - // its internal module loader (active only under this flag); demo:echo passes - // it too. NO tsx — this is the published `node lib/bin.js` path. - const child = spawn(process.execPath, ['--expose-internals', stdioBin, configArg], { - cwd, - // Mock model: never calls the network, so no key needed. - env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') }, - stdio: ['pipe', 'pipe', 'pipe'], - }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (c: string) => { stdout += c }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (c: string) => { stderr += c }) - const timer = setTimeout(() => { - child.kill('SIGKILL') - reject(new Error(`built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, 25_000) - child.on('exit', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) }) - child.on('error', (err) => { clearTimeout(timer); reject(err) }) - child.stdin.write(`${input}\n`) - child.stdin.end() - }) -} - -let consumer: string | undefined - -afterEach(async () => { - // Windows can briefly retain released handles after exit; retry removal. - if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) - consumer = undefined -}) - -describe.skipIf(!existsSync(stdioBin))('dsh-stdio-demo BUILT bin (node lib/bin.js, no tsx)', () => { - it('boots the published bin, prints its banner, and runs the echo tool round-trip', async () => { - consumer = await makeConsumer('BUILT-BIN-OK ready.') - const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'echo hi') - expect(stderr).not.toContain('UNHANDLED') - expect(stderr).not.toContain('without inject') - // The banner proves boot() awaited the tree (the settle-race regression would - // exit 0 with empty stdout); the round-trip proves the whole app mounted. - expect(stdout).toContain('BUILT-BIN-OK ready.') - expect(stdout).toContain('[tool call] echo') - expect(stdout).toContain('[tool result] ECHO: HI') - expect(code).toBe(0) - const files = await readdir(join(consumer, '.sessions'), { recursive: true }) - const log = files.find(file => file.endsWith('.jsonl.zstd')) - expect(log).toBeDefined() - const compressed = await readFile(join(consumer, '.sessions', log!)) - expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd') - expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session' }) - }, 30_000) - - it('boots cleanly when the config disables an (otherwise unresolvable) entry', async () => { - // A `disabled: true` entry settles without a fiber by design; the fail-loud entry-load - // guard must not mistake it for a failed import. The nonexistent path makes that distinction - // observable while the successful round-trip proves boot continued. - consumer = await makeConsumer('DISABLED-OK ready.', true) - const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'echo hi') - expect(stderr).not.toContain('failed to load') - expect(stdout).toContain('DISABLED-OK ready.') - expect(stdout).toContain('[tool result] ECHO: HI') - expect(code).toBe(0) - }, 30_000) - - it('runs two synchronously piped lines as two ordinary turns', async () => { - consumer = await makeConsumer('TWO-TURNS ready.') - const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'first\nsecond') - expect(stderr).not.toContain('UNHANDLED') - expect(stdout).toContain('[main turn 1]') - expect(stdout).toContain('You said: "first"') - expect(stdout).toContain('[main turn 2]') - expect(stdout).toContain('You said: "second"') - expect(code).toBe(0) - }, 30_000) - - it('boots when optional spill plugins are loaded from a built consumer install', async () => { - consumer = await makeConsumer( - 'SPILL-OK ready.', - false, - ['spill/spill', 'spill/spill-local', 'spill/spill-policy', 'util/retention'], - [ - '- id: spill-local', - ' name: \'@deepseek-ai/dsh-spill-local\'', - '- id: spill-policy', - ' name: \'@deepseek-ai/dsh-spill-policy\'', - ' config:', - ' maxInlineBytes: 50000', - ], - ) - const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', '') - expect(stderr).not.toContain('failed to load') - expect(stderr).not.toContain('Cannot find package') - expect(stdout).toContain('SPILL-OK ready.') - expect(code).toBe(0) - }, 30_000) - - it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => { - // boot() pre-resolves the bootstrap include to an absolute URL, so a nonexistent config - // directory cannot break its import; the include plugin's own read must fail loud instead. - consumer = await makeConsumer('unused') - const { code, stderr } = await runBuiltBin(consumer, '/nonexistent/dir/cordis.yml', '') - expect(code).not.toBe(0) - expect(stderr).toContain('config file not found') - }, 30_000) - - it('fails LOUD (non-zero exit + stderr) on a missing config file in a real directory', async () => { - // Existing directory plus missing config exercises the include plugin's fail-loud path. - consumer = await makeConsumer('unused') - const { code, stderr } = await runBuiltBin(consumer, './does-not-exist.yml', '') - expect(code).not.toBe(0) - expect(stderr).toContain('config file not found') - }, 30_000) -}) diff --git a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts deleted file mode 100644 index 28b9c8cecd..0000000000 --- a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { mkdtemp } from 'node:fs/promises' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' - -import type { Message } from '@deepseek-ai/dsh-llm' -import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' -import * as stdioAgent from '../src/index.ts' - -/** - * Unit coverage for app composition and config forwarding: pre-created main agent, - * agent-spine-demo spine, JSONL backend, and adaptive terminal UI. HMR is a Loader-only leaf concern covered by the - * keyless echo smoke; this tier pins the export shape because an inject-less app could otherwise - * survive namespace collapse while silently losing its schema. - */ -async function mount(config: stdioAgent.Config, withBash = false): Promise { - const ctx = new Context() - if (withBash) ctx.provide('bash', { sandboxMode: undefined }) - await ctx.plugin(stdioAgent, config) - // The app mounts its children inside apply() (not awaited there); let their - // fibers settle so the spine services + the pre-created agent are ready. - await new Promise(resolve => setTimeout(resolve, 80)) - return ctx -} - -async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promise> { - const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-demo-skills-')) - return { - local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') }, - ...catalogDescriptionMaxLength !== undefined ? { tool: { catalogDescriptionMaxLength } } : {}, - } -} - -async function composePrefix(ctx: Context): Promise { - const agent = { session: { header: { cwd: '/tmp' } } } as unknown as Agent - const empty: Message[] = [] - return await agentEvents(ctx, agent).waterfall( - 'agent/session-prefix', empty, new AbortController().signal, - () => Promise.resolve(empty), - ) -} - -async function withIsolatedSkillHomes(run: () => Promise): Promise { - const oldDshHome = process.env.DSH_HOME - const oldAgentsHome = process.env.DSH_AGENTS_HOME - const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-demo-default-skills-')) - process.env.DSH_HOME = join(home, '.dsh') - process.env.DSH_AGENTS_HOME = join(home, '.agents') - try { - return await run() - } finally { - if (oldDshHome === undefined) { - delete process.env.DSH_HOME - } else { - process.env.DSH_HOME = oldDshHome - } - if (oldAgentsHome === undefined) { - delete process.env.DSH_AGENTS_HOME - } else { - process.env.DSH_AGENTS_HOME = oldAgentsHome - } - } -} - -describe('dsh-stdio-demo app', () => { - it('selects readline for pipes and dsh-tui for interactive terminal pairs', () => { - expect(stdioAgent.resolveTerminalMode(undefined, false)).toBe('readline') - expect(stdioAgent.resolveTerminalMode(undefined, true)).toBe('tui') - expect(stdioAgent.resolveTerminalMode({ mode: 'readline' }, true)).toBe('readline') - expect(stdioAgent.resolveTerminalMode({ mode: 'tui' }, true)).toBe('tui') - expect(() => stdioAgent.resolveTerminalMode({ mode: 'tui' }, false)).toThrow('requires both stdin and stdout') - }) - - it('binds only the selected terminal package to the app-owned exact session identity', () => { - const calls: Array<{ name: string; config: unknown }> = [] - const ctx = { - plugin(plugin: { name?: string }, config?: unknown) { - calls.push({ name: plugin.name ?? '', config }) - }, - } as unknown as Context - - stdioAgent.composeTerminalApp(ctx, { - provider: 'mock', - model: 'mock', - workspaceContext: false, - persistenceCompression: 'none', - welcome: 'TUI ready', - ui: { mode: 'tui', tui: { color: false, maxToolOutputLines: 3 } }, - }, true) - expect(calls.map(call => call.name)).toContain('ui-tui') - expect(calls.map(call => call.name)).not.toContain('ui-stdio') - expect(calls.map(call => call.name)).not.toContain('ConsoleExporter') - expect(calls.find(call => (call.config as { root?: string } | undefined)?.root === './.sessions')?.config).toEqual({ - root: './.sessions', - compression: 'none', - }) - const tuiConfig = calls.find(call => call.name === 'ui-tui')?.config as { sessionId: string } - expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', color: false, maxToolOutputLines: 3 }) - expect(tuiConfig.sessionId).toMatch(/^main-session-/) - const spineConfig = calls.find(call => call.name === 'agent-spine-demo')?.config as { - agents: Array<{ id: string; sessionId?: string; resumeSessionId?: string }> - } - expect(spineConfig.agents[0]).toMatchObject({ id: 'main', sessionId: tuiConfig.sessionId }) - - calls.length = 0 - stdioAgent.composeTerminalApp(ctx, { - provider: 'mock', - model: 'mock', - resumeSessionId: 'persisted-session', - workspaceContext: false, - ui: { mode: 'tui' }, - }, true) - expect(calls.find(call => call.name === 'ui-tui')?.config).toMatchObject({ - sessionId: 'persisted-session', welcome: 'ready.', - }) - expect((calls.find(call => call.name === 'agent-spine-demo')?.config as typeof spineConfig).agents[0]) - .toMatchObject({ id: 'main', resumeSessionId: 'persisted-session' }) - - calls.length = 0 - stdioAgent.composeTerminalApp(ctx, { - provider: 'mock', model: 'mock', workspaceContext: false, ui: { mode: 'readline' }, - }, false) - expect(calls.map(call => call.name)).toContain('ui-stdio') - expect(calls.map(call => call.name)).toContain('ConsoleExporter') - expect(calls.map(call => call.name)).not.toContain('ui-tui') - }) - - it('composes the spine + front-door cluster and pre-creates the main agent', async () => { - const ctx = await mount({ provider: 'mock', model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-demo-spec', skills: await isolatedSkillsConfig(), workspaceContext: false }) - // The spine services (brought up by the agent-spine-demo bundle) are all present. - expect(ctx.get('agents')).toBeDefined() - expect(ctx.get('agentLoop')).toBeDefined() - expect(ctx.get('sessionPersistence')).toBeDefined() - expect(ctx.get('userInteraction')).toBeDefined() - expect(ctx.get('tools')?.get('ask_user_question')).toBeDefined() - // The sole pre-created agent the UI drives. `main` is its stable config - // label; each fresh process mints a durable combined agent/session id. - await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1) - const agent = ctx.get('agents')?.list()[0] - expect(agent).toBeDefined() - expect(agent?.id).toBe(agent?.session.id) - expect(agent?.id).toMatch(/^main-session-/) - expect(agent?.session.header.cwd).toBe(process.cwd()) - await ctx.fiber.dispose() - }) - - it('normalizes an empty resume id to a fresh exact app identity', async () => { - const ctx = await mount({ - provider: 'mock', - model: 'mock', - resumeSessionId: '', - persistenceRoot: '/tmp/dsh-stdio-agent-spec-empty-resume', - skills: await isolatedSkillsConfig(), - workspaceContext: false, - }) - await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1) - const agent = ctx.get('agents')?.list()[0] - expect(agent?.id).toMatch(/^main-session-[0-9a-f-]{36}$/) - expect(agent?.id).toBe(agent?.session.id) - await ctx.fiber.dispose() - }) - - it('defaults persistenceRoot and welcome when omitted', async () => { - // Direct apply (NOT via ctx.plugin, which validates+defaults the config - // first) so the runtime `DEFAULT_PERSISTENCE_ROOT` / `DEFAULT_WELCOME` fallbacks on - // apply()'s last two lines are the ones that fire — covering a - // schema-bypassing direct-mount caller. - const ctx = new Context() - // No persona: covers the omitted-persona forwarding branch too. - stdioAgent.apply(ctx, { provider: 'mock', model: 'mock', skills: await isolatedSkillsConfig(), workspaceContext: false }) - await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1) - expect(ctx.get('sessionPersistence')).toBeDefined() - expect(ctx.get('agents')?.list()[0]?.id).toMatch(/^main-session-/) - await ctx.fiber.dispose() - }) - - it('forwards explicit project-instruction controls to the bundled spine', async () => { - const ctx = await mount({ - provider: 'mock', - model: 'mock', - persona: 'hi', - persistenceRoot: '/tmp/dsh-stdio-demo-spec-workspace-context', - workspaceContext: false, - }) - await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1) - expect(ctx.get('agents')?.list()[0]?.id).toMatch(/^main-session-/) - await ctx.fiber.dispose() - }) - - it('uses default skill config when apply is called directly without skills', async () => { - await withIsolatedSkillHomes(async () => { - const ctx = new Context() - stdioAgent.apply(ctx, { provider: 'mock', model: 'mock', workspaceContext: false }) - await new Promise(resolve => setTimeout(resolve, 80)) - expect(ctx.skills).toBeDefined() - expect(await ctx.skills.list()).toEqual([]) - await ctx.fiber.dispose() - }) - }) - - it('forwards resumeSessionId onto the pre-created agent when set', async () => { - // A resume id defers agent creation until persistence loads; with no backing - // session the resume is contained + logged, so no agent registers — - // the branch that maps resumeSessionId through is what this covers. - const ctx = await mount({ - provider: 'mock', - model: 'mock', - persona: 'hi', - persistenceRoot: '/tmp/dsh-stdio-demo-spec-resume', - resumeSessionId: 'no-such-session', - skills: await isolatedSkillsConfig(), - workspaceContext: false, - }) - expect(ctx.get('agents')?.list()).toEqual([]) - await ctx.fiber.dispose() - }) - - it('forwards skill config and dshHome into agent-spine-demo', async () => { - const skills = await isolatedSkillsConfig(6) - const ctx = await mount({ provider: 'mock', model: 'mock', persona: 'hi', dshHome: skills.local!.dshHome!, skills, workspaceContext: false }) - ctx.skills.register({ name: 'stdio-skill', description: 'Stdio skill', source: 'runtime', content: 'body' }) - expect(JSON.stringify(await composePrefix(ctx))).toContain('- `stdio-skill`: Std...') - await ctx.fiber.dispose() - }) - - it('forwards maxParallelToolCalls to the bundled agent loop', async () => { - const ctx = await mount({ - provider: 'mock', - model: 'mock', - maxParallelToolCalls: 3, - persistenceRoot: '/tmp/dsh-stdio-demo-spec-parallel', - skills: await isolatedSkillsConfig(), - workspaceContext: false, - }) - expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3) - await ctx.fiber.dispose() - }) - - it('forwards bundled tool config into agent-core', async () => { - const ctx = await mount({ - provider: 'mock', - model: 'mock', - workspaceContext: false, - toolBash: { enableRunInBackground: false }, - toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 }, - skills: await isolatedSkillsConfig(), - }, true) - const bash = ctx.tools.schemas().find(tool => tool.name === 'bash') - expect(Object.keys((bash!.parameters as { properties: Record }).properties)) - .not.toContain('run_in_background') - await ctx.fiber.dispose() - }) - - it('exposes its name and Config schema', () => { - expect(stdioAgent.name).toBe('stdio-demo') - expect(stdioAgent.Config).toBeDefined() - }) - - it('forwards toolOrder through agent-spine-demo to the system-prompt assembly', async () => { - const ctx = await mount({ - provider: 'mock', - model: 'mock', - toolOrder: ['zulu', TOOL_ORDER_REST], - persistenceRoot: '/tmp/dsh-stdio-demo-spec-tool-order', - workspaceContext: false, - }) - // The bundle's own bash tools pend on the absent `ctx.bash` executor in - // this providerless mount, so register two plain tools to order. - for (const name of ['alpha', 'zulu']) { - ctx.get('tools')!.register({ - name, - description: name, - parameters: {}, - execute: async () => [], - }) - } - const assembly = await ctx.get('systemPrompt')!.assemble() - expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'ask_user_question', 'skill', 'task_kill', 'task_list', 'task_output']) - await ctx.fiber.dispose() - }) - - it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => { - // A default export would make `unwrapExports` collapse this inject-less namespace and silently - // drop `name`/`Config` while the app still boots. Guard the postmortem-0001 shape directly. - expect('default' in stdioAgent).toBe(false) - expect(typeof stdioAgent.apply).toBe('function') - - const loader = Object.create(Loader.prototype) as Loader - const unwrapped = loader.unwrapExports(stdioAgent) as Record - expect(unwrapped).toBe(stdioAgent) - expect(unwrapped.name).toBe('stdio-demo') - expect(unwrapped.Config).toBeDefined() - expect(typeof unwrapped.apply).toBe('function') - }) -}) diff --git a/packages/examples/tui-demo/README.md b/packages/examples/tui-demo/README.md new file mode 100644 index 0000000000..b544e989fc --- /dev/null +++ b/packages/examples/tui-demo/README.md @@ -0,0 +1,102 @@ +# @deepseek-ai/dsh-tui-demo + +The full-screen terminal app: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). Its `bin` boots a leaf `cordis.yml`. + +Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and other non-interactive runs. This package requires a TTY pair and has no line-oriented fallback. + +## What it bakes in + +| Plugin | Why it is here | +|---|---| +| `@deepseek-ai/dsh-agent-spine-demo` | Shared services, model-facing tools, and one configured `main` agent | +| `@deepseek-ai/dsh-session-persistence-jsonl` | Durable session log under `persistenceRoot` | +| `@deepseek-ai/dsh-user-interaction` | Provider-neutral human question service | +| `@deepseek-ai/dsh-tui` | Full-screen transcript, editor, tool cards, plan, and question overlays | +| `@deepseek-ai/dsh-tool-ask-user` | Model-facing `ask_user_question` tool | + +Swappable LLM, bash, filesystem, and other capability providers remain in the leaf config. `@cordisjs/plugin-hmr` also remains a leaf-only development entry because it requires Loader internals. + +## Config + +| Key | Default | Routed to | +|---|---|---| +| `provider` | required | Configured `main` agent provider | +| `model` | required | Configured `main` agent model | +| `maxParallelToolCalls` | agent-loop default | Bundled loop concurrency cap | +| `persona` | — | System-prompt persona template | +| `toolOrder` | lexicographic | Explicit model-facing tool order | +| `tools` | owner default | Tool presentation mode | +| `dshHome` | owner default | Harness home used by bash and skills | +| `skills` | owner defaults | Skill registry, local provider, and tool config | +| `toolBash` | owner defaults | Model-facing bash tool config | +| `toolTasks` | owner defaults | Background-task control-tool config, or `false` | +| `workspaceContext` | required | Workspace-instruction config, or `false` | +| `persistenceRoot` | `./.sessions` | JSONL persistence root | +| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) | +| `welcome` | `ready.` | TUI subtitle | +| `ui` | owner defaults | TUI presentation settings such as reasoning, color, and card height | +| `resumeSessionId` | — | Exact persisted session to resume | + +Fresh runs mint a `main-session-` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. + +## The bin + +`dsh-tui-demo [path-to-cordis.yml]` defaults to `./cordis.yml`, loads the optional cwd `.env`, boots the Cordis Loader, and waits for the full plugin tree. Bare package specifiers require `node --expose-internals` or the Loader's optional native fallback; the repository scripts use `--expose-internals`. + +## Example leaf + +```yaml +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY +- id: bash + name: '@deepseek-ai/dsh-bash-local' +- id: tui-agent + name: '@deepseek-ai/dsh-tui-demo' + config: + provider: deepseek + model: deepseek-v4-flash + workspaceContext: + maxBytes: 65536 + welcome: 'Coding agent ready.' + ui: + showReasoning: true +``` + +## Model Experience + +### Interactive terminal turn + +#### What the model sees + +Each non-empty editor submission becomes a user message; a submission during a running turn becomes steering. The shared spine contributes the configured persona, workspace instructions, skill catalog, and visible tool schemas. TUI rendering itself is not model-visible. + +#### Token effect + +User, assistant, and tool history grows under the normal session and compaction rules. Headers, cards, plans, Markdown styling, and keybindings add no tokens. + +#### KV Cache effect + +Append-only while the composed prompt, schemas, route, and retained history prefix remain stable. Composition changes and compaction can invalidate reuse from the first changed token. + +### Human-question answer + +#### What the model sees + +`ask_user_question` retains the tool call and the compact answer or stable interruption error defined by `dsh-tool-ask-user`. The question overlay is terminal-only. + +#### Token effect + +Only the completed or failed tool result adds retained tokens. + +#### KV Cache effect + +Append-only; the answer follows the reusable request prefix. + +## Known Limitations and Deferred Work + +- **TTY-only** — stdin and stdout must both be terminals; automation uses `dsh-cli-demo`. +- **One configured terminal session** — the transcript and editor bind to one exact session id. +- **The app cluster is fixed** — JSONL persistence and ask-user tooling are baked in; different policy requires another composition. +- **Approval is separate** — this app answers `ctx.userInteraction`, not `ctx.approval`; permission prompts require an approval service and answerer. diff --git a/packages/examples/stdio-demo/package.json b/packages/examples/tui-demo/package.json similarity index 83% rename from packages/examples/stdio-demo/package.json rename to packages/examples/tui-demo/package.json index 69c45bb362..c356105964 100644 --- a/packages/examples/stdio-demo/package.json +++ b/packages/examples/tui-demo/package.json @@ -1,13 +1,13 @@ { - "name": "@deepseek-ai/dsh-stdio-demo", - "description": "Terminal chat app: agent spine + JSONL persistence + TTY pi-tui/readline front-door selection + pre-created main agent", + "name": "@deepseek-ai/dsh-tui-demo", + "description": "Full-screen terminal app: agent spine + JSONL persistence + pi-tui front door + pre-created main agent", "version": "0.0.1", "private": true, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", "bin": { - "dsh-stdio-demo": "lib/bin.js" + "dsh-tui-demo": "lib/bin.js" }, "exports": { ".": { @@ -37,19 +37,17 @@ "peerDependencies": { "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "@cordisjs/plugin-logger-console": "^1.0.0", + "@deepseek-ai/dsh-app-boot": "^0.0.1", "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", - "@deepseek-ai/dsh-app-boot": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", - "@deepseek-ai/dsh-stdio": "^0.0.1", + "@deepseek-ai/dsh-tui": "^0.0.1", "@deepseek-ai/dsh-tool-ask-user": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "@deepseek-ai/dsh-tui": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", "@deepseek-ai/dsh-workspace-context": "^0.0.1", "cordis": "^4.0.0-rc.7", @@ -58,20 +56,17 @@ "devDependencies": { "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", - "@cordisjs/plugin-logger-console": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", - "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", - "@deepseek-ai/dsh-stdio": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tui": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-tui": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", "cordis": "^4.0.0-rc.7", diff --git a/packages/examples/stdio-demo/src/bin.ts b/packages/examples/tui-demo/src/bin.ts similarity index 66% rename from packages/examples/stdio-demo/src/bin.ts rename to packages/examples/tui-demo/src/bin.ts index 3d8a0c2a33..237e4391b5 100644 --- a/packages/examples/stdio-demo/src/bin.ts +++ b/packages/examples/tui-demo/src/bin.ts @@ -1,14 +1,14 @@ #!/usr/bin/env node /** - * Boot a stdio app from a leaf `cordis.yml`; usage is `dsh-stdio-demo [config]`, defaulting to the + * Boot a TUI app from a leaf `cordis.yml`; usage is `dsh-tui-demo [config]`, defaulting to the * cwd file. Shared `.env` loading, fail-loud Loader guards, and settled-tree boot live in - * dsh-app-boot. The echo-agent and repl-agent demos invoke this bin with their own leaf configs. - * @module @deepseek-ai/dsh-stdio-demo/bin + * dsh-app-boot. The tui-agent and cordis-agent demos invoke this bin with their own leaf configs. + * @module @deepseek-ai/dsh-tui-demo/bin */ import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' -const NAME = 'dsh-stdio-demo' +const NAME = 'dsh-tui-demo' /* v8 ignore start -- thin self-executing composition over the unit-tested dsh-app-boot helpers; exercised end-to-end by the keyless Loader-path and diff --git a/packages/examples/tui-demo/src/index.ts b/packages/examples/tui-demo/src/index.ts new file mode 100644 index 0000000000..c4a13948e8 --- /dev/null +++ b/packages/examples/tui-demo/src/index.ts @@ -0,0 +1,130 @@ +/** + * Full-screen terminal app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}) + * plus JSONL persistence, keyboard-backed user interaction, and one pre-created + * agent whose exact session identity the TUI drives. Swappable adapters, + * executors, optional tools, and HMR stay in the leaf. This Loader plugin + * intentionally exposes named exports only; a default export would hide its + * `Config` schema (see docs/postmortem/0001). + * @module @deepseek-ai/dsh-tui-demo + */ + +import type { Context } from 'cordis' +import { randomUUID } from 'node:crypto' +import z from 'schemastery' +import { SessionId } from '@deepseek-ai/dsh-session' +import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' +import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' +import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' +import SessionPersistenceJsonl, { + JsonlCompressionSchema, + type JsonlCompression, +} from '@deepseek-ai/dsh-session-persistence-jsonl' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' +import * as uiTui from '@deepseek-ai/dsh-tui' + +export const name = 'tui-demo' +const DEFAULT_PERSISTENCE_ROOT = './.sessions' +const DEFAULT_WELCOME = 'ready.' + +/** App config routed to the spine, TUI, configured agent, and JSONL backend. */ +export interface Config { + /** Provider route for the `main` agent. */ + provider: string + /** Model name for the `main` agent; a matching adapter must be registered. */ + model: string + /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ + maxParallelToolCalls?: number + /** Deployment persona forwarded to the system-prompt plugin. */ + persona?: string + /** Explicit model-facing tool order forwarded to the system-prompt plugin. */ + toolOrder?: string[] + /** Tool-registry presentation config forwarded through agent-spine-demo. */ + tools?: ToolsConfig + /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ + dshHome?: string + /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ + persistenceRoot?: string + /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ + persistenceCompression?: JsonlCompression + /** TUI subtitle rendered on start. Defaults to `ready.`. */ + welcome?: string + /** Full-screen TUI presentation settings. */ + ui?: uiTui.TuiConfig + /** Skill registry, local-provider, and model-facing consumer config. */ + skills?: agentCore.SkillConfig + /** Model-facing bash tool config forwarded through agent-spine-demo. */ + toolBash?: NonNullable + /** Generic background-task controls forwarded through agent-spine-demo; set false to omit them. */ + toolTasks?: NonNullable + /** Persisted session id to resume instead of creating a fresh session. */ + resumeSessionId?: string + /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ + workspaceContext: agentCore.Config['workspaceContext'] +} + +// Each front door keeps a complete Loader schema so its deployment contract is +// readable without a cross-package config facade. +/* jscpd:ignore-start */ +export const Config: z = z.object({ + provider: z.string().required(), + model: z.string().required(), + maxParallelToolCalls: z.number().step(1).min(1), + persona: z.string(), + // Absent means lexicographic order; schemastery's native array default is []. + toolOrder: z.array(z.string()).default(undefined as unknown as string[]), + tools: ToolRegistry.Config, + dshHome: z.string(), + persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT), + persistenceCompression: JsonlCompressionSchema, + welcome: z.string().default(DEFAULT_WELCOME), + ui: uiTui.TuiConfigSchema, + skills: agentCore.SkillConfigSchema, + toolBash: agentCore.ToolBashConfigSchema, + toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), + resumeSessionId: z.string(), + workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), +}) +/* jscpd:ignore-end */ + +/** + * Compose the spine, TUI, JSONL persistence, and user-question tool around one + * exact fresh or resumed session identity. The TUI subscribes to startup + * failures before the spine creates the agent. + * @param ctx - context receiving the app's child plugins. + * @param config - validated app configuration. + */ +export function composeTuiApp(ctx: Context, config: Config): void { + const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId + const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`) + ctx.plugin(SessionPersistenceJsonl, { + root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT, + ...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }), + }) + ctx.plugin(UserInteractionService) + ctx.plugin(uiTui, { + ...config.ui, + welcome: config.welcome ?? DEFAULT_WELCOME, + sessionId, + }) + ctx.plugin(agentCore, { + ...agentCore.pickSpineConfig(config), + agents: [{ + id: SessionId('main'), + provider: config.provider, + model: config.model, + cwd: process.cwd(), + ...resumeSessionId === undefined ? { sessionId } : { resumeSessionId: sessionId }, + }], + }) + ctx.plugin(toolAskUser) +} + +/** + * Compose the configured full-screen terminal app. + * @param ctx - context receiving the app's child plugins. + * @param config - validated app configuration. + */ +export function apply(ctx: Context, config: Config): void { + composeTuiApp(ctx, config) +} diff --git a/packages/examples/stdio-demo/src/invariant.ts b/packages/examples/tui-demo/src/invariant.ts similarity index 85% rename from packages/examples/stdio-demo/src/invariant.ts rename to packages/examples/tui-demo/src/invariant.ts index 93d5a73df7..e20983d7ac 100644 --- a/packages/examples/stdio-demo/src/invariant.ts +++ b/packages/examples/tui-demo/src/invariant.ts @@ -1,19 +1,19 @@ /** - * Generated invariant ownership companion for `@deepseek-ai/dsh-stdio-demo`. + * Generated invariant ownership companion for `@deepseek-ai/dsh-tui-demo`. * Replace this file with package-owned checks while preserving its registration. * * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-stdio-demo/invariant + * @module @deepseek-ai/dsh-tui-demo/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -const PACKAGE_NAME = '@deepseek-ai/dsh-stdio-demo' +const PACKAGE_NAME = '@deepseek-ai/dsh-tui-demo' /** Cordis companion plugin name. */ -export const name = 'stdio-demo-invariant' +export const name = 'tui-demo-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] diff --git a/packages/examples/tui-demo/tests/tui-agent.spec.ts b/packages/examples/tui-demo/tests/tui-agent.spec.ts new file mode 100644 index 0000000000..673f3e4f05 --- /dev/null +++ b/packages/examples/tui-demo/tests/tui-agent.spec.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import type { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' +import * as tuiAgent from '../src/index.ts' + +interface PluginCall { + readonly name: string + readonly config: unknown +} + +function recordingContext(): { readonly ctx: Context; readonly calls: PluginCall[] } { + const calls: PluginCall[] = [] + const ctx = { + plugin(plugin: { name?: string }, config?: unknown) { + calls.push({ name: plugin.name ?? '', config }) + }, + } as unknown as Context + return { ctx, calls } +} + +describe('dsh-tui-demo app', () => { + it('composes the TUI cluster around one fresh exact session identity', () => { + const { ctx, calls } = recordingContext() + tuiAgent.composeTuiApp(ctx, { + provider: 'mock', + model: 'mock-model', + maxParallelToolCalls: 3, + persona: 'test persona', + toolOrder: ['zulu', TOOL_ORDER_REST], + tools: { mode: 'code' }, + dshHome: '/tmp/dsh-home', + persistenceRoot: '/tmp/tui-sessions', + persistenceCompression: 'none', + welcome: 'TUI ready', + ui: { color: false, maxToolOutputLines: 3 }, + skills: { tool: { catalogDescriptionMaxLength: 8 } }, + toolBash: { enableRunInBackground: false }, + toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 }, + workspaceContext: false, + }) + + expect(calls.map(call => call.name)).toEqual([ + 'SessionPersistenceJsonl', + 'UserInteractionService', + 'ui-tui', + 'agent-spine-demo', + 'tool-ask-user', + ]) + expect(calls[0]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' }) + const tuiConfig = calls[2]?.config as { sessionId: string } + expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', color: false, maxToolOutputLines: 3 }) + expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) + const spineConfig = calls[3]?.config as { + readonly agents: Array> + readonly maxParallelToolCalls: number + readonly persona: string + readonly toolOrder: string[] + readonly tools: { mode: string } + } + expect(spineConfig).toMatchObject({ + maxParallelToolCalls: 3, + persona: 'test persona', + toolOrder: ['zulu', TOOL_ORDER_REST], + tools: { mode: 'code' }, + }) + expect(spineConfig.agents[0]).toMatchObject({ + id: 'main', + provider: 'mock', + model: 'mock-model', + cwd: process.cwd(), + sessionId: tuiConfig.sessionId, + }) + }) + + it('resumes the configured session and applies runtime defaults', () => { + const { ctx, calls } = recordingContext() + tuiAgent.composeTuiApp(ctx, { + provider: 'mock', + model: 'mock-model', + resumeSessionId: 'persisted-session', + workspaceContext: false, + }) + + expect(calls[0]?.config).toEqual({ root: './.sessions' }) + expect(calls[2]?.config).toEqual({ welcome: 'ready.', sessionId: 'persisted-session' }) + expect((calls[3]?.config as { agents: Array> }).agents[0]).toMatchObject({ + id: 'main', + resumeSessionId: 'persisted-session', + }) + }) + + it('normalizes an empty resume id and routes apply through the same composition', () => { + const { ctx, calls } = recordingContext() + tuiAgent.apply(ctx, { + provider: 'mock', + model: 'mock-model', + resumeSessionId: '', + workspaceContext: false, + }) + + const tuiConfig = calls[2]?.config as { sessionId: string } + expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) + expect((calls[3]?.config as { agents: Array> }).agents[0]) + .toMatchObject({ sessionId: tuiConfig.sessionId }) + }) + + it('has the namespace-plugin export shape so the Loader keeps its schema', () => { + expect(tuiAgent.name).toBe('tui-demo') + expect(tuiAgent.Config).toBeDefined() + expect('default' in tuiAgent).toBe(false) + expect(typeof tuiAgent.apply).toBe('function') + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(tuiAgent) as Record + expect(unwrapped).toBe(tuiAgent) + expect(unwrapped.name).toBe('tui-demo') + expect(unwrapped.Config).toBeDefined() + }) +}) diff --git a/packages/examples/stdio-demo/tsconfig.json b/packages/examples/tui-demo/tsconfig.json similarity index 89% rename from packages/examples/stdio-demo/tsconfig.json rename to packages/examples/tui-demo/tsconfig.json index 265ee5f35b..78b1ddc6a0 100644 --- a/packages/examples/stdio-demo/tsconfig.json +++ b/packages/examples/tui-demo/tsconfig.json @@ -20,9 +20,6 @@ { "path": "../../ui/app-boot" }, - { - "path": "../../../vendor/logger-console" - }, { "path": "../../core/agent" }, @@ -38,9 +35,6 @@ { "path": "../../ui/user-interaction" }, - { - "path": "../../ui/stdio" - }, { "path": "../../ui/tui" }, diff --git a/packages/examples/stdio-demo/tsdown.config.ts b/packages/examples/tui-demo/tsdown.config.ts similarity index 88% rename from packages/examples/stdio-demo/tsdown.config.ts rename to packages/examples/tui-demo/tsdown.config.ts index 7e907e8a18..06efc0b4db 100644 --- a/packages/examples/stdio-demo/tsdown.config.ts +++ b/packages/examples/tui-demo/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsdown' /** - * stdio-agent ships TWO entries: the plugin (`index`) and the CLI `bin` + * tui-demo ships two entries: the plugin (`index`) and the CLI `bin` * (`bin`), the latter referenced by package.json `bin`/`exports["./bin"]`. * The root tsdown builds only `lib/types/index.js`, so this override adds * `lib/types/bin.js`. Declarations come from `tsc -b` (dts: false), diff --git a/packages/sdk/create-sdk/src/args.ts b/packages/sdk/create-sdk/src/args.ts index 897159bd7c..2b156f7fb5 100644 --- a/packages/sdk/create-sdk/src/args.ts +++ b/packages/sdk/create-sdk/src/args.ts @@ -61,7 +61,7 @@ function createProgram(): Command { .option('--base-url ') .option('--api-key ') .option('--model ') - .addOption(new Option('--interface ').choices(['acp', 'stdio', 'embed'])) + .addOption(new Option('--interface ').choices(['acp', 'tui', 'embed'])) .addOption(new Option('--pm ').choices(['npm', 'pnpm', 'yarn'])) .addOption(new Option('--install').default(undefined)) .addOption(new Option('--no-install').default(undefined)) diff --git a/packages/sdk/create-sdk/src/create-questions.ts b/packages/sdk/create-sdk/src/create-questions.ts index c53e6e227e..193f1fdc25 100644 --- a/packages/sdk/create-sdk/src/create-questions.ts +++ b/packages/sdk/create-sdk/src/create-questions.ts @@ -169,10 +169,10 @@ const PROJECT_QUESTION_STEPS: readonly WizardStep[] = [ message: 'Run interface', options: [ { value: 'acp', label: 'ACP server' }, - { value: 'stdio', label: 'Terminal REPL' }, + { value: 'tui', label: 'Terminal TUI' }, { value: 'embed', label: 'Embedded context' }, ], - initialValue: 'stdio', + initialValue: 'tui', }), prefilled: state => state.args.runInterface, apply: (state, value) => { state.runInterface = value }, diff --git a/packages/sdk/create-sdk/src/templates/assets/usage.txt.tpl b/packages/sdk/create-sdk/src/templates/assets/usage.txt.tpl index 32f4d5c6d2..1842571cdd 100644 --- a/packages/sdk/create-sdk/src/templates/assets/usage.txt.tpl +++ b/packages/sdk/create-sdk/src/templates/assets/usage.txt.tpl @@ -6,7 +6,7 @@ Options: --base-url --api-key --model - --interface + --interface --pm --install / --no-install --config diff --git a/packages/sdk/create-sdk/tests/create.snapshot.ts b/packages/sdk/create-sdk/tests/create.snapshot.ts index a5ea46db53..08aa4a5a73 100644 --- a/packages/sdk/create-sdk/tests/create.snapshot.ts +++ b/packages/sdk/create-sdk/tests/create.snapshot.ts @@ -179,12 +179,12 @@ describe('create-sdk terminal contract', () => { "message": "DeepSeek API key", }, { - "initialValue": "stdio", + "initialValue": "tui", "kind": "select", "message": "Run interface", "options": [ "ACP server", - "Terminal REPL", + "Terminal TUI", "Embedded context", ], }, diff --git a/packages/sdk/create-sdk/tests/create.spec.ts b/packages/sdk/create-sdk/tests/create.spec.ts index c7c74c9659..f6dc8e1709 100644 --- a/packages/sdk/create-sdk/tests/create.spec.ts +++ b/packages/sdk/create-sdk/tests/create.spec.ts @@ -151,7 +151,7 @@ describe('create arguments', () => { expect(() => parseCreateArgs(['--link-packages-workspace'])).toThrow("unknown option '--link-packages-workspace'") expect(parseCreateArgs(['--provider=custom']).provider).toBe('custom') expect(parseCreateArgs(['--help']).help).toBe(true) - expect(() => parseCreateArgs(['--interface=bad'])).toThrow('Allowed choices are acp, stdio, embed') + expect(() => parseCreateArgs(['--interface=bad'])).toThrow('Allowed choices are acp, tui, embed') expect(() => parseCreateArgs(['--unknown'])).toThrow("unknown option '--unknown'") expect(() => parseCreateArgs(['one', 'two'])).toThrow('too many arguments') }) @@ -208,7 +208,7 @@ describe('CreateWizard and scaffolder', () => { '--provider=deepseek', '--api-key=deepseek-key', '--model=deepseek-v4-flash', - '--interface=stdio', + '--interface=tui', '--pm=npm', '--no-install', '--link-workspace', @@ -247,7 +247,7 @@ describe('CreateWizard and scaffolder', () => { const resolved = await new CreateWizard({ args: parseCreateArgs([ 'my-agent', '--description=demo', '--provider=deepseek', '--api-key=deepseek-key', - '--model=deepseek-v4-flash', '--interface=stdio', '--pm=npm', '--no-install', + '--model=deepseek-v4-flash', '--interface=tui', '--pm=npm', '--no-install', ]), port: new HeadlessPromptPort(), cwd, @@ -275,7 +275,7 @@ describe('CreateWizard and scaffolder', () => { await expect(new CreateWizard({ args: parseCreateArgs([ 'my-agent', '--description=demo', '--provider=deepseek', '--api-key=k', - '--model=m', '--interface=stdio', '--pm=npm', '--no-install', + '--model=m', '--interface=tui', '--pm=npm', '--no-install', ]), port: new HeadlessPromptPort(), cwd, diff --git a/packages/sdk/helper/src/features/builtin/app.ts b/packages/sdk/helper/src/features/builtin/app.ts index e4ff11af20..8e7793ab8f 100644 --- a/packages/sdk/helper/src/features/builtin/app.ts +++ b/packages/sdk/helper/src/features/builtin/app.ts @@ -29,7 +29,7 @@ const ID = featureId('app') function appProjectResources( profile: ProjectProfile, - runInterface: 'acp' | 'stdio' | 'embed', + runInterface: 'acp' | 'tui' | 'embed', ): readonly ProjectResource[] { const context = createProjectTemplateContext(profile, runInterface) const scripts = createAppPackageScripts(context) @@ -43,10 +43,10 @@ function appProjectResources( } class AppOption extends FeatureOption { - override readonly id: 'acp' | 'stdio' | 'embed' + override readonly id: 'acp' | 'tui' | 'embed' override readonly label: string - constructor(id: 'acp' | 'stdio' | 'embed', label: string) { + constructor(id: 'acp' | 'tui' | 'embed', label: string) { super() this.id = id this.label = label @@ -56,7 +56,7 @@ class AppOption extends FeatureOption { override markerConfigEntries(): readonly { id: string; name: string }[] { switch (this.id) { case 'acp': return [{ id: 'acp', name: '@deepseek-ai/dsh-acp' }] - case 'stdio': return [{ id: 'stdio', name: '@deepseek-ai/dsh-stdio' }] + case 'tui': return [{ id: 'tui', name: '@deepseek-ai/dsh-tui' }] case 'embed': return [] } } @@ -65,7 +65,7 @@ class AppOption extends FeatureOption { override matchesConfigEntries(entries: readonly { id: string; name: string }[], profile: ProjectProfile): boolean { if (this.id !== 'embed') return super.matchesConfigEntries(entries, profile) return entries.some(entry => entry.id === 'agent-loop' && entry.name === '@deepseek-ai/dsh-agent-loop') - && !entries.some(entry => entry.name === '@deepseek-ai/dsh-acp' || entry.name === '@deepseek-ai/dsh-stdio') + && !entries.some(entry => entry.name === '@deepseek-ai/dsh-acp' || entry.name === '@deepseek-ai/dsh-tui') } override contribution(profile: ProjectProfile): ProjectContribution { @@ -83,7 +83,7 @@ class AppOption extends FeatureOption { config: { model: profile.runtime.model }, }, ['model'], config => requiredString(config, 'model')), ]) - case 'stdio': + case 'tui': return new ProjectContribution([ ...appProjectResources(profile, this.id), ...npmCordisConfigEntry(ID, { @@ -91,10 +91,10 @@ class AppOption extends FeatureOption { name: '@deepseek-ai/dsh-user-interaction', }), ...npmCordisConfigEntry(ID, { - id: 'stdio', - name: '@deepseek-ai/dsh-stdio', + id: 'tui', + name: '@deepseek-ai/dsh-tui', config: { - welcome: 'agent REPL ready. Give it a coding task.', + welcome: 'TUI agent ready. Give it a coding task.', sessionId: new JsExpression('process.env.DSH_SDK_SESSION_ID'), }, }, ['welcome', 'sessionId'], config => [ @@ -108,7 +108,7 @@ class AppOption extends FeatureOption { } } -/** Required app selection represented by acp, stdio, or embed options. */ +/** Required app selection represented by ACP, TUI, or embed options. */ export class AppFeature extends ExclusiveOptionFeature { override readonly id = ID override readonly summary = 'Run interface' @@ -116,7 +116,7 @@ export class AppFeature extends ExclusiveOptionFeature { override readonly requires = [featureId('spine')] override readonly options = [ new AppOption('acp', 'ACP server'), - new AppOption('stdio', 'Terminal REPL'), + new AppOption('tui', 'Terminal TUI'), new AppOption('embed', 'Embedded context'), ] diff --git a/packages/sdk/helper/src/features/builtin/index.ts b/packages/sdk/helper/src/features/builtin/index.ts index c4043e7d59..29889b438e 100644 --- a/packages/sdk/helper/src/features/builtin/index.ts +++ b/packages/sdk/helper/src/features/builtin/index.ts @@ -347,7 +347,7 @@ config: id: 'ask-user', summary: 'Ask the user from the model loop', mode: 'single', - supportedInterfaces: ['acp', 'stdio'], + supportedInterfaces: ['acp', 'tui'], options: [{ id: 'default', label: 'ask_user_question tool', diff --git a/packages/sdk/helper/src/features/define-feature.ts b/packages/sdk/helper/src/features/define-feature.ts index 7c90a2853a..6b726d42d6 100644 --- a/packages/sdk/helper/src/features/define-feature.ts +++ b/packages/sdk/helper/src/features/define-feature.ts @@ -250,7 +250,7 @@ class DefinedFeature extends Feature { this.required = spec.required ?? false this.requires = (spec.requires ?? []).map(requirement => featureId(requirement.id)) this.suggests = (spec.suggests ?? []).map(featureId) - this.supportedInterfaces = spec.supportedInterfaces ?? ['acp', 'stdio', 'embed'] + this.supportedInterfaces = spec.supportedInterfaces ?? ['acp', 'tui', 'embed'] } override defaultOptions(): readonly string[] { diff --git a/packages/sdk/helper/src/features/feature.ts b/packages/sdk/helper/src/features/feature.ts index 1335d8e29b..b77deb8093 100644 --- a/packages/sdk/helper/src/features/feature.ts +++ b/packages/sdk/helper/src/features/feature.ts @@ -113,7 +113,7 @@ export abstract class Feature { /** Features recommended during creation. */ readonly suggests: readonly FeatureId[] = [] /** Front doors under which this feature is meaningful. */ - readonly supportedInterfaces: readonly RunInterface[] = ['acp', 'stdio', 'embed'] + readonly supportedInterfaces: readonly RunInterface[] = ['acp', 'tui', 'embed'] /** * Options selected when installation has no override. diff --git a/packages/sdk/helper/src/project/project-edit-session.ts b/packages/sdk/helper/src/project/project-edit-session.ts index 0d0a1cb6dd..d027d74e08 100644 --- a/packages/sdk/helper/src/project/project-edit-session.ts +++ b/packages/sdk/helper/src/project/project-edit-session.ts @@ -549,7 +549,7 @@ export class ProjectEditSession implements FeatureProjectView { private finalProfile(): ProjectProfile { const runInterface = this.states.get(featureId('app'))?.selection?.options[0] - if (runInterface !== 'acp' && runInterface !== 'stdio' && runInterface !== 'embed') return this.profile + if (runInterface !== 'acp' && runInterface !== 'tui' && runInterface !== 'embed') return this.profile return { ...this.profile, runInterface } } diff --git a/packages/sdk/helper/src/project/sdk-project.ts b/packages/sdk/helper/src/project/sdk-project.ts index cd55ffe2e5..a24a08b3df 100644 --- a/packages/sdk/helper/src/project/sdk-project.ts +++ b/packages/sdk/helper/src/project/sdk-project.ts @@ -42,7 +42,7 @@ const OPTIONAL_DOCUMENTS = [ function runInterface(entries: readonly CordisConfigEntry[]): RunInterface { if (entries.some(entry => entry.name === '@deepseek-ai/dsh-acp')) return 'acp' - if (entries.some(entry => entry.name === '@deepseek-ai/dsh-stdio')) return 'stdio' + if (entries.some(entry => entry.name === '@deepseek-ai/dsh-tui')) return 'tui' return 'embed' } @@ -146,7 +146,7 @@ export class SdkProject { static create(root: string, request: ProjectCreationRequest): SdkProject { const app = request.features.find(selection => selection.id === 'app') const selectedInterface = app?.options[0] - if (selectedInterface !== 'acp' && selectedInterface !== 'stdio' && selectedInterface !== 'embed') { + if (selectedInterface !== 'acp' && selectedInterface !== 'tui' && selectedInterface !== 'embed') { throw new Error('project creation requires one app feature option') } const profile: ProjectProfile = { diff --git a/packages/sdk/helper/src/project/types.ts b/packages/sdk/helper/src/project/types.ts index 44d06d508c..11fca01b8d 100644 --- a/packages/sdk/helper/src/project/types.ts +++ b/packages/sdk/helper/src/project/types.ts @@ -9,7 +9,7 @@ import type { LocalPluginBlueprint } from '../plugins/local-plugin-blueprint.ts' import type { FeatureId } from '../ids.ts' /** Runtime front door selected for a generated project. */ -export type RunInterface = 'acp' | 'stdio' | 'embed' +export type RunInterface = 'acp' | 'tui' | 'embed' /** Values shared by the required provider and app features. */ interface ProjectRuntimeOptions { diff --git a/packages/sdk/helper/src/templates/assets/README.md.tpl b/packages/sdk/helper/src/templates/assets/README.md.tpl index 0033c8e0d4..bdaef06c4e 100644 --- a/packages/sdk/helper/src/templates/assets/README.md.tpl +++ b/packages/sdk/helper/src/templates/assets/README.md.tpl @@ -9,7 +9,7 @@ Built with the DeepSeek Harness SDK using the {{model}} model. Run `{{packageManager}} start` and configure your ACP client to launch this project. Standard output is reserved for ACP JSON-RPC. {{else}} -{{#if isStdio}} +{{#if isTui}} ## Run in a terminal Run `{{packageManager}} start` to start the interactive agent. diff --git a/packages/sdk/helper/src/templates/assets/index.ts.tpl b/packages/sdk/helper/src/templates/assets/index.ts.tpl index a79818908c..311c6746cf 100644 --- a/packages/sdk/helper/src/templates/assets/index.ts.tpl +++ b/packages/sdk/helper/src/templates/assets/index.ts.tpl @@ -8,18 +8,18 @@ import { startSDK, type SdkBootContext } from '@deepseek-ai/dsh-scripts' /** Boot this project's cordis.yml when invoked by dsh-scripts. */ export async function main(boot: SdkBootContext) { -{{#if isStdio}} +{{#if isTui}} const model = boot.args.model - if (typeof model !== 'string' || model.length === 0) throw new Error('stdio startup requires --model=') + if (typeof model !== 'string' || model.length === 0) throw new Error('TUI startup requires --model=') const resume = boot.args.resume if (resume !== undefined && (typeof resume !== 'string' || resume.length === 0)) { - throw new Error('stdio startup requires --resume=') + throw new Error('TUI startup requires --resume=') } const sessionId = SessionId(resume ?? `main-session-${randomUUID()}`) process.env.DSH_SDK_SESSION_ID = sessionId {{/if}} const ctx = await startSDK(new URL('./cordis.yml', import.meta.url)) -{{#if isStdio}} +{{#if isTui}} try { if (resume === undefined) { await ctx.agents.create({ @@ -37,7 +37,7 @@ export async function main(boot: SdkBootContext) { try { await ctx.fiber.dispose() } catch (disposeError) { - throw new AggregateError([error, disposeError], 'stdio startup and cleanup failed') + throw new AggregateError([error, disposeError], 'TUI startup and cleanup failed') } throw error } diff --git a/packages/sdk/helper/src/templates/project-template.ts b/packages/sdk/helper/src/templates/project-template.ts index b214126b6e..afcf820ec2 100644 --- a/packages/sdk/helper/src/templates/project-template.ts +++ b/packages/sdk/helper/src/templates/project-template.ts @@ -20,7 +20,7 @@ export interface ProjectTemplateContext { model: string modelLiteral: string isAcp: boolean - isStdio: boolean + isTui: boolean isEmbed: boolean packageManager: PackageManagerName installArgs: string @@ -60,7 +60,7 @@ export function createProjectTemplateContext( model: profile.runtime.model, modelLiteral: JSON.stringify(profile.runtime.model), isAcp: runInterface === 'acp', - isStdio: runInterface === 'stdio', + isTui: runInterface === 'tui', isEmbed: runInterface === 'embed', packageManager: profile.packageManager.name, installArgs: profile.packageManager.installCommand().join(' '), @@ -105,7 +105,7 @@ export function createAppProjectArtifacts( /** Build package scripts owned by the selected app feature option. */ export function createAppPackageScripts(context: ProjectTemplateContext): Readonly> { - const modelArg = context.isStdio ? ` -- --model=${JSON.stringify(context.model)}` : '' + const modelArg = context.isTui ? ` -- --model=${JSON.stringify(context.model)}` : '' return { dev: `dsh-sdk dev index.ts${modelArg}`, start: `dsh-sdk start index.js${modelArg}`, diff --git a/packages/sdk/helper/tests/documents.spec.ts b/packages/sdk/helper/tests/documents.spec.ts index 7181820725..1e4b944f00 100644 --- a/packages/sdk/helper/tests/documents.spec.ts +++ b/packages/sdk/helper/tests/documents.spec.ts @@ -243,7 +243,7 @@ overrides: expect(() => loadHelperTemplate('../bad.tpl')).toThrow('must not contain a directory') expect(createBaselineProjectArtifacts({ name: 'demo', description: 'demo', releaseVersion: '0.0.1', model: 'model', modelLiteral: '"model"', packageManager: 'yarn', - isAcp: false, isStdio: false, isEmbed: true, + isAcp: false, isTui: false, isEmbed: true, installArgs: 'install', buildArgs: 'build', }).map(document => document.relativePath)).toContain('.yarnrc.yml') expect(() => new LocalPluginBlueprint('---', 'plugin')).toThrow('invalid local plugin name') diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index 1512a40bc0..c04183fb6f 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -51,7 +51,7 @@ function selection(id: string, options: readonly string[], secrets?: Record { expect(acp.readEnvironment('.env', 'KEY')).toBe('value') expect(() => acp.readEnvironment('.env.example', 'KEY')).not.toThrow() expect(acp.document('tsconfig.json')).toBeInstanceOf(TextProjectFile) - const stdio = await make('dsh-open-stdio', {}, `- id: provider + const tui = await make('dsh-open-tui', {}, `- id: provider name: '@deepseek-ai/dsh-llm-deepseek' config: { models: [provider-model] } -- id: stdio - name: '@deepseek-ai/dsh-stdio' +- id: tui + name: '@deepseek-ai/dsh-tui' `, { 'yarn.lock': '' }) - expect(stdio.profile.runInterface).toBe('stdio') - expect(stdio.profile.runtime.model).toBe('provider-model') - expect(stdio.profile.packageManager.name).toBe('yarn') - expect(stdio.profile.name).toBe(stdio.root.split('/').at(-1)) + expect(tui.profile.runInterface).toBe('tui') + expect(tui.profile.runtime.model).toBe('provider-model') + expect(tui.profile.packageManager.name).toBe('yarn') + expect(tui.profile.name).toBe(tui.root.split('/').at(-1)) const pnpm = await make('dsh-open-pnpm', { name: 'pnpm' }, '[]\n', { 'pnpm-lock.yaml': '' }) expect(pnpm.profile.packageManager.name).toBe('pnpm') const defaults = await make('dsh-open-default', { name: 'default', packageManager: 'npm@10.0.0' }, '[]\n') @@ -134,8 +134,8 @@ describe('SdkProject and ProjectEditSession', () => { expect(() => SdkProject.create(defaults.root, { ...request(), features: [] })).toThrow('requires one app') await expect(make('dsh-open-invalid-manager', { name: 'bad', packageManager: 'bad' }, '[]\n')) .rejects.toThrow('invalid packageManager field') - const providerFallback = await make('dsh-open-provider-fallback', { name: 'fallback' }, `- id: stdio - name: '@deepseek-ai/dsh-stdio' + const providerFallback = await make('dsh-open-provider-fallback', { name: 'fallback' }, `- id: tui + name: '@deepseek-ai/dsh-tui' config: { model: '' } - id: provider name: '@deepseek-ai/dsh-llm-deepseek' @@ -172,7 +172,7 @@ describe('SdkProject and ProjectEditSession', () => { expect(index).toContain('process.env.DSH_SDK_SESSION_ID = sessionId') expect(index).toContain('resumeSessionId: sessionId') expect(index).toContain('await ctx.fiber.dispose()') - expect(index).toContain("new AggregateError([error, disposeError], 'stdio startup and cleanup failed')") + expect(index).toContain("new AggregateError([error, disposeError], 'TUI startup and cleanup failed')") expect(project.packageManifest().scripts).toEqual({ dev: 'dsh-sdk dev index.ts -- --model="deepseek-v4-flash"', build: 'dsh-sdk build', @@ -181,12 +181,12 @@ describe('SdkProject and ProjectEditSession', () => { config: 'dsh-sdk config', }) expect(await readFile(join(project.root, '.env.example'), 'utf8')).toContain('EXA_API_KEY=') - expect(project.cordis.entry('stdio')?.config?.sessionId).toMatchObject({ + expect(project.cordis.entry('tui')?.config?.sessionId).toMatchObject({ source: 'process.env.DSH_SDK_SESSION_ID', }) expect(await readFile(join(project.root, 'cordis.yml'), 'utf8')) .toContain('sessionId: !!js process.env.DSH_SDK_SESSION_ID') - expect(project.cordis.entry('stdio')?.config).not.toHaveProperty('model') + expect(project.cordis.entry('tui')?.config).not.toHaveProperty('model') expect(project.cordis.entry('agent-loop')?.config).toEqual({ agents: [] }) expect(project.cordis.entry('session-invariant')?.name).toBe('@deepseek-ai/dsh-session/invariant') expect(project.cordis.entry('agent-invariant')?.name).toBe('@deepseek-ai/dsh-agent/invariant') @@ -217,13 +217,13 @@ describe('SdkProject and ProjectEditSession', () => { expect(app.selection).toEqual(selection('app', ['embed'])) expect(committed.cordis.entry('agent-loop')?.config).toEqual({ agents: [] }) expect(committed.cordis.entry('acp')).toBeUndefined() - expect(committed.cordis.entry('stdio')).toBeUndefined() + expect(committed.cordis.entry('tui')).toBeUndefined() }) it('emits the sandbox workspace-write example as inactive Cordis config', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-sandbox-bash-')) temporary.push(root) - const creation = request([], [], 'stdio', 'sandbox') + const creation = request([], [], 'tui', 'sandbox') const project = SdkProject.create(root, creation) const registry = createBuiltinRegistry(project.profile) const edit = project.edit(registry) @@ -312,7 +312,7 @@ describe('SdkProject and ProjectEditSession', () => { const modifiedRegistry = createBuiltinRegistry(modified.profile) expect(() => { modified.edit(modifiedRegistry).configureFeature( modifiedRegistry.get(featureId('app')), - selection('app', ['stdio']), + selection('app', ['tui']), ) }).toThrow('feature-owned file was modified: README.md') const manifest = PackageJsonFile.parse(await readFile(join(embed.root, 'package.json'), 'utf8')) @@ -355,7 +355,7 @@ describe('SdkProject and ProjectEditSession', () => { const edit = project.edit(registry) edit.setCustomPluginDisabled('sample', true) expect(edit.cordisConfigEntries().find(entry => entry.id === 'sample')?.disabled).toBe(true) - expect(() => { edit.setCustomPluginDisabled('stdio', true) }).toThrow('builtin feature') + expect(() => { edit.setCustomPluginDisabled('tui', true) }).toThrow('builtin feature') const next = (await edit.commit()).project const enable = next.edit(createBuiltinRegistry(next.profile)) enable.setCustomPluginDisabled('sample', false) @@ -450,8 +450,8 @@ describe('SdkProject and ProjectEditSession', () => { } const internals = edit as unknown as Internals const collidingEntry: ProjectResource = { - kind: 'cordis-config-entry', key: resourceKey('cordis-config-entry:stdio'), - entry: { id: 'stdio', name: 'other-package' }, ownedConfigKeys: [], + kind: 'cordis-config-entry', key: resourceKey('cordis-config-entry:tui'), + entry: { id: 'tui', name: 'other-package' }, ownedConfigKeys: [], } expect(() => { internals.applyResource(collidingEntry, undefined) }).toThrow('is owned by') const existingFile: ProjectResource = { @@ -803,7 +803,7 @@ describe('extension points', () => { }) expect(exclusive.defaultOptions(profile)).toEqual(['one']) expect(exclusive.isApplicable(profile)).toBe(true) - expect(exclusive.isApplicable({ ...profile, runInterface: 'stdio' })).toBe(false) + expect(exclusive.isApplicable({ ...profile, runInterface: 'tui' })).toBe(false) expect(exclusive.requirements(selection('defined', ['one']))).toEqual([ { id: 'base' }, { id: 'option', options: ['required'] }, ]) @@ -817,7 +817,7 @@ describe('extension points', () => { expect(entry?.validateConfig?.({ nested: { value: 2 }, list: ['a', 'b'], nullable: null })).toEqual([]) expect(entry?.validateConfig?.({ nested: [], list: 'bad' })).toHaveLength(3) expect(() => exclusive.normalizeSelection(selection('other', ['one']), profile)).toThrow('does not belong') - expect(() => exclusive.normalizeSelection(selection('defined', ['one']), { ...profile, runInterface: 'stdio' })) + expect(() => exclusive.normalizeSelection(selection('defined', ['one']), { ...profile, runInterface: 'tui' })) .toThrow('not available') expect(() => exclusive.normalizeSelection(selection('defined', ['missing']), profile)).toThrow('unknown') expect(() => exclusive.normalizeSelection(selection('defined', ['one', 'two']), profile)).toThrow('exactly one') @@ -825,7 +825,7 @@ describe('extension points', () => { id: 'fixed', summary: 'Fixed', mode: 'single', options: [option], }])).toHaveLength(2) expect(() => new FeatureRegistry([], profile).get(featureId('missing'))).toThrow('unknown feature') - expect(new FeatureRegistry([exclusive], profile).ownerOfPackage('one-package', { ...profile, runInterface: 'stdio' })) + expect(new FeatureRegistry([exclusive], profile).ownerOfPackage('one-package', { ...profile, runInterface: 'tui' })) .toBeUndefined() class Unsupported extends FixedFeature { override readonly id = featureId('unsupported') @@ -908,10 +908,10 @@ describe('extension points', () => { resource.kind === 'cordis-config-entry' && resource.entry.id === 'acp') expect(acpEntry?.entry.id).toBe('acp') expect(acpEntry?.validateConfig?.({ model: '' })).toHaveLength(1) - const stdioEntry = builtins.get(featureId('app')).contribution(selection('app', ['stdio']), profile).resources + const tuiEntry = builtins.get(featureId('app')).contribution(selection('app', ['tui']), profile).resources .find((resource): resource is CordisConfigEntryResource => - resource.kind === 'cordis-config-entry' && resource.entry.id === 'stdio') - expect(stdioEntry?.validateConfig?.({ welcome: 'ready', sessionId: 1 })).toEqual([ + resource.kind === 'cordis-config-entry' && resource.entry.id === 'tui') + expect(tuiEntry?.validateConfig?.({ welcome: 'ready', sessionId: 1 })).toEqual([ 'sessionId must be a non-empty string', ]) const embedOption = app.options.find(option => option.id === 'embed') @@ -921,7 +921,7 @@ describe('extension points', () => { ]) expect(embedOption?.matchesConfigEntries([ { id: 'agent-loop', name: '@deepseek-ai/dsh-agent-loop' }, - { id: 'stdio', name: '@deepseek-ai/dsh-stdio' }, + { id: 'tui', name: '@deepseek-ai/dsh-tui' }, ], profile)).toBe(false) const spineAgentLoop = builtins.get(featureId('spine')).contribution(selection('spine', ['default']), profile).resources .find((resource): resource is CordisConfigEntryResource => diff --git a/packages/sdk/helper/tests/questions.spec.ts b/packages/sdk/helper/tests/questions.spec.ts index 5eb205075f..dc9e734ac5 100644 --- a/packages/sdk/helper/tests/questions.spec.ts +++ b/packages/sdk/helper/tests/questions.spec.ts @@ -376,7 +376,7 @@ describe('feature configurator', () => { name: 'demo', description: 'demo', runtime: { model: 'deepseek-v4-flash' }, - runInterface: 'stdio', + runInterface: 'tui', packageManager: new NpmPackageManager('10.0.0'), releaseVersion: '0.0.1', } diff --git a/packages/sdk/scripts/src/config/config-workflow.ts b/packages/sdk/scripts/src/config/config-workflow.ts index 016d9d09b8..408a9b9639 100644 --- a/packages/sdk/scripts/src/config/config-workflow.ts +++ b/packages/sdk/scripts/src/config/config-workflow.ts @@ -56,7 +56,7 @@ function targetRunInterface( desired: ReadonlyMap>, ): RunInterface { const selected = desired.get('feature:app')?.choices[0] - return selected === 'acp' || selected === 'stdio' || selected === 'embed' ? selected : current + return selected === 'acp' || selected === 'tui' || selected === 'embed' ? selected : current } /** Reconcile one tree selection into domain commands, then review and commit once. */ diff --git a/packages/sdk/scripts/tests/__snapshots__/config.snapshot.ts.snap b/packages/sdk/scripts/tests/__snapshots__/config.snapshot.ts.snap index 31e21dd8a9..14f74b0f85 100644 --- a/packages/sdk/scripts/tests/__snapshots__/config.snapshot.ts.snap +++ b/packages/sdk/scripts/tests/__snapshots__/config.snapshot.ts.snap @@ -90,8 +90,8 @@ Change file: package.json }, { "default": true, - "label": "Terminal REPL", - "value": "stdio", + "label": "Terminal TUI", + "value": "tui", }, { "default": false, diff --git a/packages/sdk/scripts/tests/config.snapshot.ts b/packages/sdk/scripts/tests/config.snapshot.ts index 80a79a5f20..e6047c8562 100644 --- a/packages/sdk/scripts/tests/config.snapshot.ts +++ b/packages/sdk/scripts/tests/config.snapshot.ts @@ -94,7 +94,7 @@ async function baseProject(): Promise { features: [ { id: featureId('provider'), options: ['deepseek'], secrets: { apiKey: 'key' } }, { id: featureId('bash'), options: ['local'] }, - { id: featureId('app'), options: ['stdio'] }, + { id: featureId('app'), options: ['tui'] }, { id: featureId('persistence'), options: ['jsonl'] }, ], localPlugins: [], diff --git a/packages/sdk/scripts/tests/scripts.spec.ts b/packages/sdk/scripts/tests/scripts.spec.ts index 8b887d74db..a6ec4f4a4f 100644 --- a/packages/sdk/scripts/tests/scripts.spec.ts +++ b/packages/sdk/scripts/tests/scripts.spec.ts @@ -85,7 +85,7 @@ function commandContext(cwd: string): DshSdkCommandContext & { readStdout: () => function creation( extra: ProjectCreationRequest['features'] = [], localPlugins: readonly LocalPluginBlueprint[] = [], - app: 'acp' | 'stdio' | 'embed' = 'embed', + app: 'acp' | 'tui' | 'embed' = 'embed', ): ProjectCreationRequest { return { name: 'config-agent', @@ -107,7 +107,7 @@ function creation( async function committedProject( extra: ProjectCreationRequest['features'] = [], localPlugins: readonly LocalPluginBlueprint[] = [], - app: 'acp' | 'stdio' | 'embed' = 'embed', + app: 'acp' | 'tui' | 'embed' = 'embed', ): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-config-workflow-')) temporary.push(root) @@ -525,7 +525,7 @@ describe('ConfigWorkflow', () => { const workflow = new ConfigWorkflow(new QueuePort([ [ { value: 'feature:provider', choices: ['custom'] }, - { value: 'feature:app', choices: ['stdio'] }, + { value: 'feature:app', choices: ['tui'] }, { value: 'feature:persistence', choices: ['jsonl'] }, ], 'https://provider.example/v1', @@ -536,7 +536,7 @@ describe('ConfigWorkflow', () => { const provider = result.commit?.project.cordis.entry('llm-pi-ai') expect(provider?.config?.apiKey).toBeDefined() expect(provider?.config?.baseURL).toBe('https://provider.example/v1') - expect(result.commit?.project.cordis.entry('stdio')).toBeDefined() + expect(result.commit?.project.cordis.entry('tui')).toBeDefined() expect(result.commit?.project.cordis.entry('agent-loop')).toBeDefined() expect(result.commit?.project.cordis.entry('agent-core')).toBeUndefined() }) diff --git a/packages/support/README.md b/packages/support/README.md index 433d6b3cdb..045b69d390 100644 --- a/packages/support/README.md +++ b/packages/support/README.md @@ -10,4 +10,4 @@ Packages that exist to serve development, testing, and the examples rather than | `loader-smoke/` | Shared real-Loader subprocess harness for keyless example smokes | (library — imported by example e2e suites) | | `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | -`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-spine-demo` bundle mounts it unconditionally. `agent-loop-testkit` centralizes the mandatory service spine for hand-built AgentLoop tests without owning their loop or scenario. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the ACP subprocess/client boundary plus the snapshot harness, normalizers, and suite machinery, while `loader-smoke` owns the parallel stdio/Loader process boundary used by keyless example e2e suites. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. +`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-spine-demo` bundle mounts it unconditionally. `agent-loop-testkit` centralizes the mandatory service spine for hand-built AgentLoop tests without owning their loop or scenario. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the ACP subprocess/client boundary plus the snapshot harness, normalizers, and suite machinery, while `loader-smoke` owns the parallel real-Loader launch boundary used by keyless example e2e suites. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. diff --git a/packages/support/loader-smoke/tests/example-launch.spec.ts b/packages/support/loader-smoke/tests/example-launch.spec.ts index 20520e6fb6..77a0791516 100644 --- a/packages/support/loader-smoke/tests/example-launch.spec.ts +++ b/packages/support/loader-smoke/tests/example-launch.spec.ts @@ -5,7 +5,7 @@ import { resolveExampleMode, } from '@deepseek-ai/dsh-loader-smoke' -const SRC_BIN = '/repo/packages/examples/stdio-demo/src/bin.ts' +const SRC_BIN = '/repo/packages/examples/tui-demo/src/bin.ts' const TSCONFIG = '/repo/tsconfig.json' const originalMode = process.env[EXAMPLE_MODE_ENV] @@ -66,7 +66,7 @@ describe('resolveExampleLaunch', () => { env: { DSH_HOME: '/tmp/home' }, }) expect(args).not.toContain('--import') - expect(args).toContain('/repo/packages/examples/stdio-demo/lib/bin.js') + expect(args).toContain('/repo/packages/examples/tui-demo/lib/bin.js') expect(args.slice(-2)).toEqual(['--config', './cordis.yml']) expect(env.TSX_TSCONFIG_PATH).toBeUndefined() expect(env.DSH_HOME).toBe('/tmp/home') @@ -106,6 +106,6 @@ describe('resolveExampleLaunch', () => { it('defaults the mode from the environment', () => { process.env[EXAMPLE_MODE_ENV] = 'lib' const { args } = resolveExampleLaunch({ srcBin: SRC_BIN }) - expect(args).toContain('/repo/packages/examples/stdio-demo/lib/bin.js') + expect(args).toContain('/repo/packages/examples/tui-demo/lib/bin.js') }) }) diff --git a/packages/todo/README.md b/packages/todo/README.md index bfe5ec7503..c19fab82d3 100644 --- a/packages/todo/README.md +++ b/packages/todo/README.md @@ -6,4 +6,4 @@ The model-facing todo tool. A single **product** package — there is no interfa |---|---|---| | `tool-todo/` | Model-facing `todo_write` tool; writes the whole list to the session log (`todo/write`) | (registers on `ctx.tools`) | -The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [terminal app](../examples/stdio-demo) shows a persistent TUI plan or readline checklist, while the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate. +The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [TUI app](../examples/tui-demo) shows a persistent plan, while the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate. diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index 6323d86247..2ccdd7699e 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -18,7 +18,7 @@ Beyond the schema's type/required/enum checks, `execute` rejects an empty or dup ## Rendering -The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [terminal app](../../examples/stdio-demo) shows a persistent TUI plan or readline checklist, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires). +The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [TUI app](../../examples/tui-demo) shows a persistent plan, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires). ## Export shape diff --git a/packages/ui/README.md b/packages/ui/README.md index 3dd26d80a8..bd6b6c9406 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -9,13 +9,12 @@ Integrations that expose the agent to an external editor or client. These are ** | `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` | | `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` | | `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) | -| `stdio/` | Line-oriented terminal channel for pipes and automation; drives `ctx.agents`, renders `session/event`, and answers `ctx.userInteraction` | (drives `ctx.agents`) | | `tui/` | Interactive pi-tui terminal channel for TTY sessions; renders `session/event`, tool presentation intents, and answers `ctx.userInteraction` | (drives `ctx.agents`) | | `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) | | `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | -A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). The [`stdio`](stdio/README.md) and [`tui`](tui/README.md) plugins are the two terminal front doors: one is line-oriented for pipes, the other is interactive for TTYs. App bundles and SDK projects compose the appropriate channel explicitly with the services and tools their product profile selects. +A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). [`tui`](tui/README.md) is the interactive terminal front door; non-interactive tasks use the headless `cli-demo` app instead of a UI channel. `user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers. -The runnable app bundles that bake these bridges into boot bins — the terminal chat app, the ACP server app, and the JSON-RPC SDK-runtime bin — live in [`examples/`](../examples/README.md) (`stdio-demo`, `acp-demo`, `jsonrpc-demo`), each composed over the [`agent-spine-demo`](../examples/agent-spine-demo/README.md) bundle. `ui/` keeps the reusable bridge/channel plugins and the `app-boot` glue; each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools. +The runnable app bundles that bake these bridges into boot bins — the TUI app, ACP server app, and JSON-RPC SDK-runtime bin — live in [`examples/`](../examples/README.md) (`tui-demo`, `acp-demo`, `jsonrpc-demo`), each composed over the [`agent-spine-demo`](../examples/agent-spine-demo/README.md) bundle. `ui/` keeps the reusable bridge/channel plugins and the `app-boot` glue; each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools. diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 9841d1ce2e..8063964c03 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -2,7 +2,7 @@ Agent Client Protocol bridge over JSON-RPC stdio. Editors can create or resume agents, stream their events, answer questions and approvals, and render tool calls. One connection supports multiple isolated sessions; Zed is the primary compatibility target. -It is a **client-driver / UI plugin**, the structured analogue of the terminal `dsh-tui`/`dsh-stdio` channels — NOT a loop change and NOT a [capability seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`. +It is a **client-driver / UI plugin**, the structured analogue of the terminal `dsh-tui` channel — NOT a loop change and NOT a [capability seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`. ## Service / plugin diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 852b1f9f9c..d874d8c154 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -1,6 +1,6 @@ # `@deepseek-ai/dsh-app-boot` -Shared boot glue for the app bins ([`dsh-stdio-demo`](../../examples/stdio-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between two published artifacts. +Shared boot glue for the app bins ([`dsh-tui-demo`](../../examples/tui-demo/README.md), [`dsh-cli-demo`](../../examples/cli-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between published artifacts. | Export | Role | |---|---| diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 91ab0d3a2f..e2413fa736 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -1,5 +1,5 @@ /** - * Shared boot glue for the app bins (`dsh-stdio-demo`, `dsh-acp-demo`): load the gitignored + * Shared boot glue for the app bins (`dsh-tui-demo`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored * `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), and * drive the cordis Loader against a leaf `cordis.yml` until the whole tree has settled. * @module @deepseek-ai/dsh-app-boot diff --git a/packages/ui/stdio/README.md b/packages/ui/stdio/README.md deleted file mode 100644 index 0c1e2b685d..0000000000 --- a/packages/ui/stdio/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# @deepseek-ai/dsh-stdio - -The terminal readline front door for DeepSeek Harness agents. It reads prompts from stdin, sends or steers them through `ctx.agents`, renders the durable `session/event` transcript to stdout, and answers `ctx.userInteraction` requests in the same terminal. Failed turns render their durable `turn/end` reason — `[turn failed ]`, `[turn aborted]`, `[turn rejected]`, `[turn interrupted …]`, or the output-token-limit notice — so a provider or network failure is never silent; unknown merge-extended reason kinds fall through as ordinary turn ends. - -This package owns the terminal channel only. It injects `agents` and `userInteraction`, then drives an agent created or resumed by app or developer code. The agent spine, agent lifecycle, console logger, and model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. - -## Config - -| Key | Default | Meaning | -|---|---|---| -| `welcome` | `ready.` | Banner printed before the first prompt | -| `sessionId` | `main` | Exact agent/session identity driven by stdin and observed for EOF shutdown | - -The plugin seeds display labels from the live agent registry, then tracks `agent/created` and `agent/disposed` so HMR and externally managed agents render consistently. While an initial exact identity is pending, it buffers nonblank input until `agent/session-start` and observes live `agent-loop/config-start-failed`; a matching failure drops queued lines, reports the loss, and lets piped EOF finish instead of hanging. The composing app must mount this front door before its config-created agent. Disposal closes readline and unregisters every listener/provider through Cordis effects. - -```yaml -- id: stdio - name: '@deepseek-ai/dsh-stdio' - config: - welcome: 'agent REPL ready. Give it a coding task.' - sessionId: main -``` - -## Model Experience - -### Readline prompt input - -#### What the model sees - -Each non-empty terminal line outside an active question becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. - -#### Token effect - -Submitted text is retained under the agent loop's normal session-history and compaction rules. The welcome banner, `> ` prompt, rendered transcript, and `[tool call]` / `[tool result]` terminal lines add no tokens. A replacement `tool/result` remains model-visible through the session surface but is not rendered as a second execution; stdio keeps the original full-fidelity result line. - -#### KV Cache effect - -Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. - -### Terminal user-interaction answers - -#### What the model sees - -When a consumer calls `ctx.userInteraction.ask()`, this provider renders the question in the terminal and returns selected option labels or `custom` text. Through `dsh-tool-ask-user`, closed stdin becomes `Error: ask_user_question cannot be answered because stdin is closed`; disposal or abort becomes `Error: ask_user_question was interrupted before the user answered`. - -#### Token effect - -Waiting and terminal prompts add no tokens; the resolved answer or error is model-visible only through the calling tool or plugin's result. - -#### KV Cache effect - -Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. - -## Known Limitations and Deferred Work - -- **One configured session receives stdin** — the session/event renderer can print output from any session, but input lines always drive the configured `sessionId` rather than routing by the visible label. -- **Terminal questions are text-only and sequential** — the provider queues asks, supports option labels plus custom text, and has no richer UI shapes such as file pickers or diff previews. -- **Closed stdin ends the terminal channel** — EOF rejects active or queued questions and exits after submitted work reaches idle; there is no reconnect path for a long-lived process. diff --git a/packages/ui/stdio/package.json b/packages/ui/stdio/package.json deleted file mode 100644 index f8473af7a3..0000000000 --- a/packages/ui/stdio/package.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-stdio", - "description": "Terminal readline front door for driving and rendering DeepSeek Harness agents over stdio", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ], - "license": "BSD-3-Clause", - "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-agent-loop": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-user-interaction": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "peerDependenciesMeta": { - "@deepseek-ai/dsh-agent-loop": { - "optional": true - } - }, - "dependencies": { - "schemastery": "^3.18.0" - }, - "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-user-interaction": "workspace:^", - "cordis": "^4.0.0-rc.7" - } -} diff --git a/packages/ui/stdio/src/index.ts b/packages/ui/stdio/src/index.ts deleted file mode 100644 index 0ea8c1a504..0000000000 --- a/packages/ui/stdio/src/index.ts +++ /dev/null @@ -1,478 +0,0 @@ -/** - * The stdio app's readline UI: reads lines from stdin into `agent.send()` or - * `steer()`, renders the durable event stream to stdout, buffers startup input - * for one exact agent/session identity, and exits piped input only after - * submitted work reaches idle. - * - * This package is the independently composable stdio front door. It establishes - * the terminal channel and drives an agent created or resumed by app or - * developer code. - * @module @deepseek-ai/dsh-stdio - */ - -import { createInterface } from 'node:readline' -import type { Readable, Writable } from 'node:stream' -import type { Context } from 'cordis' -import z from 'schemastery' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { errorChain } from '@deepseek-ai/dsh-llm' -import type {} from '@deepseek-ai/dsh-agent-loop' -import { SessionId } from '@deepseek-ai/dsh-session' -import { - UserInteractionError, - type AskUserQuestionAnswer, - type AskUserQuestionAnswerItem, - type AskUserQuestionItem, - type AskUserQuestionOption, - type AskUserQuestionRequest, -} from '@deepseek-ai/dsh-user-interaction' - -export const name = 'ui-stdio' -export const inject = ['agents', 'userInteraction'] - -/** Serializable plugin configuration (cordis-native, schemastery). */ -export interface Config { - /** Banner printed once on start, before the first `> ` prompt. */ - welcome?: string - /** Exact shared agent/session identity stdin drives. Defaults to `'main'`. */ - sessionId?: string -} - -export const Config: z = z.object({ - welcome: z.string().default('ready.'), - sessionId: z.string().default('main'), -}) - -/** - * Process-I/O seam — the side-effecting handles the plugin would otherwise - * reach for as globals. Defaulted to the real `process` streams in - * {@link apply}; injected by tests so the EOF, render, and disposal branches - * are exercised without hijacking globals. Deliberately NOT part of the - * serializable {@link Config} (streams/functions don't belong in YAML config). - */ -export interface StdioRuntime { - /** Line source (default `process.stdin`). */ - input: Readable - /** Render sink (default `process.stdout`). */ - output: Writable - /** Process-exit hook (default `process.exit`); called once on stdin EOF. */ - exit: (code: number) => void -} - -function isTTYPair(input: Readable, output: Writable): boolean { - return Boolean((input as { isTTY?: boolean }).isTTY && (output as { isTTY?: boolean }).isTTY) -} - -interface PendingQuestion { - request: AskUserQuestionRequest - questionIndex: number - answers: AskUserQuestionAnswerItem[] - resolve(answer: AskUserQuestionAnswer): void - reject(error: unknown): void - onAbort: () => void -} - -type OptionSelection = - | { kind: 'selected'; options: AskUserQuestionOption[] } - | { kind: 'custom' } - | { kind: 'invalid' } - -/** - * The plugin body, parameterized over its I/O runtime. `apply` is the thin - * production wrapper that binds the real `process` streams; tests call this - * directly with fakes. Returns nothing — all registration is via `ctx.on`/ - * `ctx.effect`, so fiber disposal tears every listener and the readline - * interface down. - * @param ctx - the context supplying the `agents` service and the event feeds. - * @param config - the plugin config; defaults are re-applied here for direct - * callers that bypass Loader validation. - * @param runtime - the process-I/O seam (line source, render sink, exit hook). - */ -export function createStdioChat(ctx: Context, config: Config, runtime: StdioRuntime): void { - // Default here too (not just via schemastery's `.default()`): this helper is - // exported and called directly by tests / programmatic consumers that bypass - // Loader validation, so it must be self-contained rather than trusting the - // cast — `config.welcome as string` would otherwise be `undefined` on `{}`. - const welcome = config.welcome ?? 'ready.' - const sessionId = SessionId(config.sessionId ?? 'main') - const { input, output, exit } = runtime - - // Bind only to the exact identity this app passed to its config-created - // agent. Session ids are opaque: neither a prefix nor registry order can - // identify ownership. The root check rejects a child that somehow preempts - // the configured id; later recreation under the same id supports loop HMR. - const matchesConfiguredIdentity = (agent: Agent): boolean => - agent.id === sessionId && ctx.agents.roots().includes(agent) - let target: Agent | undefined = ctx.agents.roots().find(agent => agent.id === sessionId) - - // Transcript rendering off the durable `session/event` feed — the assistant - // token stream, turn/step boundaries, tool activity, and todos all come from - // the one canonical stream (no agent/* mirrors). A single listener over the - // append order keeps `inReasoning` transitions deterministic across chunk and - // boundary events. - let inReasoning = false - ctx.on('session/event', (session, event) => { - if (event.type === 'assistant/chunk') { - const { chunk } = event.data - if (chunk.type === 'reasoning-delta') { - // Dim the chain-of-thought so the final answer stands out. - if (!inReasoning) output.write('\x1B[2m') - inReasoning = true - output.write(chunk.text) - } else if (chunk.type === 'text-delta') { - if (inReasoning) output.write('\x1B[0m\n') - inReasoning = false - output.write(chunk.text) - } - } else if (event.type === 'turn/start') { - const label = target?.session === session ? 'main' : session.id - output.write(`\n[${label} turn ${event.data.turn}] `) - } else if (event.type === 'turn/end') { - if (inReasoning) output.write('\x1B[0m') - inReasoning = false - // Failure reasons must reach the terminal: turn/end is the durable record - // of an in-turn failure, and without this line a failed turn renders as - // silence. Merge-extensible unknown kinds fall through as ordinary ends. - const { reason } = event.data - if (reason.kind === 'error') { - output.write(`\n[turn failed${reason.code === undefined ? '' : ` ${reason.code}`}] ${reason.message}`) - } else if (reason.kind === 'aborted') { - output.write(`\n[turn aborted]${reason.reason === undefined ? '' : ` ${reason.reason}`}`) - } else if (reason.kind === 'rejected') { - output.write(`\n[turn rejected] ${reason.reason}`) - } else if (reason.kind === 'max-tokens') { - output.write('\n[turn hit the output-token limit]') - } else if (reason.kind === 'interrupted') { - output.write('\n[turn interrupted by a previous process exit]') - } - output.write('\n> ') - } else if (event.type === 'tool/call') { - const { name: toolName, arguments: args } = event.data - if (inReasoning) output.write('\x1B[0m') - inReasoning = false - output.write(`\n [tool call] ${toolName}(${args})`) - } else if (event.type === 'tool/result') { - // A surface replacement changes future model context; it is not another - // execution. Keep the original full-fidelity terminal presentation and - // suppress duplicate output during live delivery or log replay. - if (event.surfaceOp !== undefined && event.surfaceOp !== 'append') return - const { content } = event.data - const text = content.filter(block => block.type === 'text').map(block => block.text).join('') - output.write(`\n [tool result] ${text}\n `) - } else if (event.type === 'todo/write') { - if (inReasoning) output.write('\x1B[0m') - inReasoning = false - const glyph = (status: string): string => - status === 'completed' ? '[x]' : status === 'in_progress' ? '[~]' : '[ ]' - const lines = event.data.todos.map(todo => ` ${glyph(todo.status)} ${todo.content}`).join('\n') - output.write(`\n [todos]\n${lines}\n `) - } - }) - - ctx.effect(() => { - // Piped-input exit, once stdin reaches EOF: - // - If no line ever submitted work (empty stdin, blank-only lines), exit - // immediately — no turn will ever start, so there is nothing to wait - // for. (Gating on an observed 'running' here would hang forever.) - // - If work WAS submitted, exit the next time the agent settles to idle - // AFTER having run. Later lines may steer the active turn, and consecutive - // queued turns can share one running interval, so we don't count inputs; - // agent.send() also does NOT synchronously flip status to - // 'running', so requiring an observed 'running' first (`sawRunning`) - // avoids exiting in the gap before the turn starts and dropping work. - let stdinClosed = false - let disposed = false - let submittedWork = false - let sawRunning = false - let exitTimer: ReturnType | undefined - let activeQuestion: PendingQuestion | undefined - const questionQueue: PendingQuestion[] = [] - const queuedInput: string[] = [] - let targetReady = target !== undefined - let hadReadyTarget = targetReady - let failedStartup: { error: unknown } | undefined - - const submit = (agent: Agent, text: string): void => { - submittedWork = true - if (agent.status === 'running') { - agent.steer([{ type: 'text', text }]) - } else { - agent.send([{ type: 'text', text }]) - } - } - - const disposeCreatedListener = ctx.on('agent/created', (agent) => { - if (!matchesConfiguredIdentity(agent)) return - target = agent - targetReady = false - failedStartup = undefined - }) - const disposeSessionStartListener = ctx.on('agent/session-start', (agent) => { - if (agent !== target) return - targetReady = true - hadReadyTarget = true - for (const text of queuedInput.splice(0)) submit(agent, text) - }) - const disposeDisposedListener = ctx.on('agent/disposed', (agent) => { - if (target !== agent) return - target = undefined - targetReady = false - }) - const reader = createInterface({ input, output, terminal: isTTYPair(input, output) }) - - const maybeExit = (): void => { - if (disposed || !stdinClosed) return - // No work submitted: nothing will ever run, exit straight away. - // Work submitted: wait until a turn has run and the agent is idle. - if (submittedWork) { - if (!sawRunning) return - const agent = target - if (agent && agent.status !== 'idle') return // a turn is still running - } - // Let any final output flush, then exit. The handle is tracked so the - // disposer can cancel it — a dispose within the flush window must not let - // the process exit out from under HMR. Re-entrant `maybeExit` calls (e.g. - // repeated idle signals) coalesce onto the one pending timer. - if (exitTimer !== undefined) { - return // exit already scheduled — coalesce re-entrant calls - } - exitTimer = setTimeout(() => { exit(0) }, 200) - } - - const disposeStartupFailedListener = ctx.on('agent-loop/config-start-failed', (failedSessionId, error) => { - if (failedSessionId !== sessionId || targetReady) return - failedStartup = { error } - const dropped = queuedInput.length - queuedInput.length = 0 - submittedWork = sawRunning - if (dropped > 0) { - ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (${dropped} line(s)): ${errorChain(error)}`) - } - maybeExit() - }) - - const disposeStatusListener = ctx.on('agent/status', (subject, status) => { - if (subject !== target) return - if (status === 'running') sawRunning = true - if (status === 'idle') maybeExit() - }) - - const activeQuestionItem = (pending: PendingQuestion): AskUserQuestionItem => - pending.request.questions[pending.questionIndex] as AskUserQuestionItem - - const renderQuestion = (pending: PendingQuestion): void => { - const question = activeQuestionItem(pending) - const options = question.options ?? [] - output.write('\n') - output.write(question.header ? `[${question.header}] ${question.question}\n` : `${question.question}\n`) - options.forEach((option, index) => { - output.write(` ${index + 1}. ${option.label}\n`) - if (option.description) output.write(` ${option.description}\n`) - }) - output.write('> ') - } - - const removeAbortListener = (pending: PendingQuestion): void => { - pending.request.signal?.removeEventListener('abort', pending.onAbort) - } - - const startNextQuestion = (): void => { - if (activeQuestion !== undefined) return - const pending = questionQueue.shift() - if (pending === undefined) return - // The queue never contains an aborted pending ask: the seam rejects an - // already-aborted request synchronously, and queued asks attach their - // abort listener before enqueueing. - activeQuestion = pending - renderQuestion(pending) - } - - const disposeQuestion = (pending: PendingQuestion): void => { - removeAbortListener(pending) - pending.reject(new UserInteractionError('ask_user_question was interrupted before the user answered', 'ASK_ABORTED')) - } - - const disposePendingQuestions = (): void => { - if (activeQuestion !== undefined) { - disposeQuestion(activeQuestion) - activeQuestion = undefined - } - for (const pending of questionQueue.splice(0)) { - disposeQuestion(pending) - } - } - - const finishQuestion = (pending: PendingQuestion): void => { - activeQuestion = undefined - removeAbortListener(pending) - pending.resolve({ answers: pending.answers }) - output.write('\n') - startNextQuestion() - } - - const answerCurrentQuestion = (pending: PendingQuestion, answer: AskUserQuestionAnswerItem): void => { - pending.answers.push(answer) - pending.questionIndex += 1 - if (pending.questionIndex >= pending.request.questions.length) { - finishQuestion(pending) - return - } - renderQuestion(pending) - } - - const selectedOptions = (text: string, options: AskUserQuestionOption[], multiSelect: boolean): OptionSelection => { - if (text === '') return { kind: 'invalid' } - if (!multiSelect) { - if (!/^\d+$/.test(text)) return { kind: 'custom' } - const selected = options[Number(text) - 1] - return selected === undefined ? { kind: 'invalid' } : { kind: 'selected', options: [selected] } - } - const indices = text.split(/[,\s]+/).filter(Boolean) - if (indices.length === 0) return { kind: 'invalid' } - if (indices.some(part => !/^\d+$/.test(part))) return { kind: 'custom' } - const uniqueIndices = [...new Set(indices)] - const selected = uniqueIndices.map(part => options[Number(part) - 1]) - return selected.some(option => option === undefined) - ? { kind: 'invalid' } - : { kind: 'selected', options: selected as AskUserQuestionOption[] } - } - - const answerQuestion = (line: string): void => { - const pending = activeQuestion as PendingQuestion - const question = activeQuestionItem(pending) - - const text = line.trim() - const options = question.options ?? [] - const selection = options.length > 0 - ? selectedOptions(text, options, question.multiSelect ?? false) - : { kind: text === '' ? 'invalid' : 'custom' } as OptionSelection - if (selection.kind === 'selected') { - answerCurrentQuestion(pending, { id: question.id, selected: selection.options.map(option => option.label) }) - return - } - - if (selection.kind === 'custom' && text !== '') { - answerCurrentQuestion(pending, { id: question.id, selected: [], custom: text }) - return - } - - output.write(options.length > 0 - ? 'Please enter one of the option numbers' - + (question.multiSelect ? ' (comma or space separated)' : '') - + ' or a custom answer' - + '.\n> ' - : 'Please enter an answer.\n> ') - } - - const disposeUserInteractionProvider = ctx.userInteraction.registerProvider({ - ask(request) { - if (disposed || stdinClosed) { - return Promise.reject( - new UserInteractionError('ask_user_question cannot be answered because stdin is closed', 'ASK_ABORTED'), - ) - } - return new Promise((resolve, reject) => { - const pending: PendingQuestion = { - request, - questionIndex: 0, - answers: [], - resolve, - reject, - onAbort: () => { - if (activeQuestion === pending) { - activeQuestion = undefined - disposeQuestion(pending) - startNextQuestion() - return - } - // If it is not active, this listener can only fire while the ask - // remains queued; settled asks remove the listener first. - questionQueue.splice(questionQueue.indexOf(pending), 1) - disposeQuestion(pending) - }, - } - request.signal?.addEventListener('abort', pending.onAbort, { once: true }) - questionQueue.push(pending) - startNextQuestion() - }) - }, - }) - - reader.on('line', (line) => { - if (activeQuestion !== undefined) { - answerQuestion(line) - return - } - const text = line.trim() - if (!text) return - if (failedStartup !== undefined) { - ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ${errorChain(failedStartup.error)}`) - return - } - const agent = target - if (agent === undefined || !targetReady) { - // Initial exact-id restoration is asynchronous. Preserve input until - // session-start, the first supported point for queueing agent work. - // After a previously ready target disappears, a line in the HMR gap - // still fails loud unless its exact replacement is already publishing. - if (!hadReadyTarget || agent !== undefined) { - submittedWork = true - queuedInput.push(text) - return - } - ctx.logger.error('ui-stdio: main agent is not running') - return - } - submit(agent, text) - }) - reader.on('close', () => { - // Fires for BOTH stdin EOF and plugin disposal (reader.close() below); - // `disposed` guards teardown so HMR/dispose never exits the process. - stdinClosed = true - if (!disposed) disposePendingQuestions() - maybeExit() - }) - output.write(`${welcome}\n> `) - return () => { - disposed = true - if (exitTimer !== undefined) clearTimeout(exitTimer) - disposePendingQuestions() - disposeUserInteractionProvider() - disposeStatusListener() - disposeCreatedListener() - disposeSessionStartListener() - disposeDisposedListener() - disposeStartupFailedListener() - reader.close() - } - }, 'ui-stdio') -} - -/** - * Open the terminal channel for one exact identity. The chat registers before - * that agent necessarily exists so it can buffer startup input and observe a - * config-start failure instead of leaving piped stdin hanging. - * @param ctx - the context supplying the agent registry and event stream. - * @param config - presentation and target-agent configuration. - * @param runtime - process-I/O seam. - */ -export function mountStdio(ctx: Context, config: Config, runtime: StdioRuntime): void { - createStdioChat(ctx, config, runtime) -} - -/** - * Cordis entry point. Binds the real `process` streams and delegates to - * {@link mountStdio}; the indirection keeps the side-effecting handles out - * of the testable core, which is why the unit suite drives `createStdioChat` - * directly. This thin wrapper is exercised end-to-end by the keyless - * Loader-path e2e smoke in `examples/echo-agent` (the real product entry). - */ -/* v8 ignore start -- production stdio wiring; testable core is createStdioChat() (covered), exercised e2e by echo-agent keyless smoke */ -export function apply(ctx: Context, config: Config): void { - mountStdio(ctx, config, { - input: process.stdin, - output: process.stdout, - exit: code => process.exit(code), - }) -} -/* v8 ignore stop */ diff --git a/packages/ui/stdio/tests/plugin-shape.spec.ts b/packages/ui/stdio/tests/plugin-shape.spec.ts deleted file mode 100644 index 5b2b35f65e..0000000000 --- a/packages/ui/stdio/tests/plugin-shape.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from 'vitest' -import Loader from '@cordisjs/plugin-loader' -import * as stdio from '../src/index.ts' - -/** Real Loader export-path guard for the namespace stdio plugin. */ -describe('dsh-stdio plugin export shape', () => { - it('preserves name, inject, Config, and apply through Loader unwrapping', () => { - expect('default' in stdio).toBe(false) - expect(typeof stdio.apply).toBe('function') - - const loader = Object.create(Loader.prototype) as Loader - const unwrapped = loader.unwrapExports(stdio) as Record - expect(unwrapped).toBe(stdio) - expect(unwrapped.name).toBe('ui-stdio') - expect(unwrapped.inject).toEqual(['agents', 'userInteraction']) - expect(unwrapped.Config).toBeDefined() - expect(typeof unwrapped.apply).toBe('function') - }) -}) diff --git a/packages/ui/stdio/tests/readline.spec.ts b/packages/ui/stdio/tests/readline.spec.ts deleted file mode 100644 index 6a97eab06a..0000000000 --- a/packages/ui/stdio/tests/readline.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { EventEmitter } from 'node:events' -import type { Readable, Writable } from 'node:stream' -import { describe, expect, it, vi } from 'vitest' -import type { Context } from 'cordis' -import type { StdioRuntime } from '../src/index.ts' - -const createInterface = vi.hoisted(() => vi.fn(() => { - const reader = new EventEmitter() as EventEmitter & { close(): void } - reader.close = vi.fn() - return reader -})) - -vi.mock('node:readline', () => ({ createInterface })) - -function fakeContext(): Context { - return { - on: vi.fn(() => vi.fn()), - effect: vi.fn((callback: () => () => void) => callback()), - // The UI seeds its root target from the registry at install; this suite only - // exercises readline terminal-mode selection, so an empty roster suffices. - agents: { roots: vi.fn(() => []) }, - userInteraction: { registerProvider: vi.fn(() => vi.fn()) }, - } as unknown as Context -} - -function fakeRuntime(inputIsTTY: boolean, outputIsTTY: boolean): StdioRuntime { - return { - input: { isTTY: inputIsTTY } as Readable & { isTTY: boolean }, - output: { isTTY: outputIsTTY, write: vi.fn(() => true) } as unknown as Writable & { isTTY: boolean }, - exit: vi.fn(), - } -} - -describe('createStdioChat readline mode', () => { - it('enables terminal editing only when both stdio streams are TTYs', async () => { - const { createStdioChat } = await import('../src/index.ts') - - const tty = fakeRuntime(true, true) - createStdioChat(fakeContext(), {}, tty) - expect(createInterface).toHaveBeenLastCalledWith({ - input: tty.input, - output: tty.output, - terminal: true, - }) - - const piped = fakeRuntime(true, false) - createStdioChat(fakeContext(), {}, piped) - expect(createInterface).toHaveBeenLastCalledWith({ - input: piped.input, - output: piped.output, - terminal: false, - }) - }) -}) diff --git a/packages/ui/stdio/tests/stdio.spec.ts b/packages/ui/stdio/tests/stdio.spec.ts deleted file mode 100644 index f1cfa3d4b6..0000000000 --- a/packages/ui/stdio/tests/stdio.spec.ts +++ /dev/null @@ -1,1095 +0,0 @@ -import { Readable, Writable } from 'node:stream' -import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import AgentRegistry, { agentEvents, type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent' -import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' -import { scopeTarget } from '@deepseek-ai/dsh-scope' -import { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' -import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import { createStdioChat, mountStdio, type Config, type StdioRuntime } from '../src/index.ts' - -/** - * Unit tests for the stdio UI plugin. They drive the REAL plugin body - * (`createStdioChat`) with an injected {@link StdioRuntime} so every render, - * input, EOF, and disposal branch runs without touching the real `process` - * streams — the I/O seam is what makes the per-file gate reachable. The - * `agents` service is real (`@deepseek-ai/dsh-agent`); a minimal fake `Agent` - * stands in for the loop, since the loop is the genuinely expensive collaborator - * and we only need its `status` + `send`/`steer` surface here. - */ - -/** A controllable stdin: a Readable we push lines into and can end on demand. */ -function makeInput(): Readable & { feed(line: string): void; finish(): void } { - const stream = new Readable({ read() {} }) as Readable & { feed(line: string): void; finish(): void } - stream.feed = (line: string) => stream.push(`${line}\n`) - stream.finish = () => stream.push(null) - return stream -} - -/** A stdout sink that accumulates everything written, for assertions. */ -function makeOutput(): { write: (s: string) => boolean; text: () => string } { - let buf = '' - return { write: (s: string) => { buf += s; return true }, text: () => buf } -} - -function makeRuntime(over: Partial = {}): { - runtime: StdioRuntime - input: ReturnType - out: ReturnType - exit: ReturnType -} { - const input = makeInput() - const out = makeOutput() - const exit = vi.fn() - return { runtime: { input, output: { write: out.write } as never, exit, ...over }, input, out, exit } -} - -/** A minimal Agent fake exposing the surface the UI touches. */ -function makeAgent(id: string, status: AgentStatus = 'idle'): Agent & { - status: AgentStatus - sent: ContentBlock[][] - steered: ContentBlock[][] -} { - const sent: ContentBlock[][] = [] - const steered: ContentBlock[][] = [] - return { - id: id as Agent['id'], - status, - sent, - steered, - // A minimal session stub with the agent's shared durable identity. - session: { id, header: { id } }, - send: (content: ContentBlock[]) => void sent.push(content), - steer: (content: ContentBlock[]) => void steered.push(content), - } as never -} - -/** Register a fake configured agent and cross the supported startup-work boundary. */ -function registerReady(ctx: Context, agent: Agent, source: 'startup' | 'resume' = 'startup'): () => void { - const dispose = ctx.agents.register(agent) - agentEvents(ctx, agent).emit('agent/session-start', source) - return dispose -} - -function emitAgentSessionStart(ctx: Context, agent: Agent, source: 'startup' | 'resume'): void { - agentEvents(ctx, agent).emit('agent/session-start', source) -} - -function emitAgentStatus(ctx: Context, agent: Agent, status: AgentStatus): void { - agentEvents(ctx, agent).emit('agent/status', status) -} - -function emitAgentDisposed(ctx: Context, agent: Agent): void { - agentEvents(ctx, agent).emit('agent/disposed') -} - -function emitSessionEvent(ctx: Context, session: Session, event: SessionEvent): void { - ctx.emit(scopeTarget(session, undefined), 'session/event', session, event) -} - -/** A session stub whose `header.id` matches an agent's, for `session/event` emits. */ -function makeSession(id: string): Session { - return { id, header: { id } } as Session -} - -/** An `assistant/chunk` session event carrying one raw stream chunk. */ -function chunkEvent(chunk: StreamChunk): SessionEvent { - return { type: 'assistant/chunk', seq: 0, time: 0, data: { turn: 1, step: 0, chunk } } -} - -const CONFIG: Config = { welcome: 'hi there', sessionId: 'main' } - -function unrenderableFailure(): unknown { - return { [Symbol.toPrimitive](): never { throw new Error('coercion escaped') } } -} - -async function setup(config: Config = CONFIG, runtimeOver: Partial = {}) { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(UserInteractionService) - const { runtime, input, out, exit } = makeRuntime(runtimeOver) - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - createStdioChat(inner, config, runtime) - }, { inject: ['agents', 'userInteraction'] })) - return { ctx, fiber, input, out, exit } -} - -/** Drive a fake idle timer past the 200ms flush delay. */ -function flushExit(): Promise { - return new Promise(resolve => setTimeout(resolve, 250)) -} - -describe('mountStdio readiness', () => { - it('opens before the configured agent is created so startup input can queue', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(UserInteractionService) - const { runtime, out } = makeRuntime() - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - mountStdio(inner, CONFIG, runtime) - }, { inject: ['agents', 'userInteraction'] })) - - expect(out.text()).toBe('hi there\n> ') - ctx.agents.register(makeAgent('other')) - expect(out.text()).toBe('hi there\n> ') - ctx.agents.register(makeAgent('main')) - expect(out.text()).toBe('hi there\n> ') - await fiber.dispose() - }) - - it('opens immediately when the configured agent already exists', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(UserInteractionService) - ctx.agents.register(makeAgent('main')) - const { runtime, out } = makeRuntime() - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - mountStdio(inner, CONFIG, runtime) - }, { inject: ['agents', 'userInteraction'] })) - - expect(out.text()).toBe('hi there\n> ') - await fiber.dispose() - }) - - it('opens for the default main identity when no target is configured', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(UserInteractionService) - const { runtime, out } = makeRuntime() - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - mountStdio(inner, { welcome: 'ready' }, runtime) - }, { inject: ['agents', 'userInteraction'] })) - - expect(out.text()).toBe('ready\n> ') - ctx.agents.register(makeAgent('other')) - expect(out.text()).toBe('ready\n> ') - ctx.agents.register(makeAgent('main')) - expect(out.text()).toBe('ready\n> ') - await fiber.dispose() - }) -}) - -describe('createStdioChat rendering', () => { - it('writes the welcome banner and prompt on start', async () => { - const { out } = await setup() - expect(out.text()).toBe('hi there\n> ') - }) - - it('falls back to the default welcome when called with empty config', async () => { - // createStdioChat is exported and may be driven directly (bypassing the - // Loader's schemastery validation), so it must default the welcome itself. - const { out } = await setup({}) - expect(out.text()).toBe('ready.\n> ') - }) - - it('detects readline terminal mode from both stream TTY flags', async () => { - for (const [inputTTY, outputTTY] of [[true, false], [true, true]] as const) { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(UserInteractionService) - let text = '' - const output = new Writable({ - write(chunk, _encoding, callback) { - text += String(chunk) - callback() - }, - }) as Writable & { isTTY?: boolean } - const { runtime } = makeRuntime({ output }) - ;(runtime.input as Readable & { isTTY?: boolean }).isTTY = inputTTY - output.isTTY = outputTTY - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - createStdioChat(inner, CONFIG, runtime) - }, { inject: ['agents', 'userInteraction'] })) - - expect(text).toContain('hi there') - await fiber.dispose() - } - }) - - it('renders text-delta chunks verbatim', async () => { - const { ctx, out } = await setup() - emitSessionEvent(ctx, makeSession('main'), chunkEvent({ type: 'text-delta', index: 0, text: 'hello' })) - expect(out.text()).toContain('hello') - }) - - it('wraps reasoning-delta in the dim SGR and resets on the following text-delta', async () => { - const { ctx, out } = await setup() - const session = makeSession('main') - emitSessionEvent(ctx, session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'think' })) - emitSessionEvent(ctx, session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'more' })) - emitSessionEvent(ctx, session, chunkEvent({ type: 'text-delta', index: 0, text: 'answer' })) - expect(out.text()).toContain('\x1B[2mthinkmore\x1B[0m\nanswer') - }) - - it('ignores stream-chunk types it does not render', async () => { - const { ctx, out } = await setup() - const before = out.text() - emitSessionEvent(ctx, makeSession('main'), chunkEvent({ type: 'block-start', index: 0, blockType: 'text' })) - expect(out.text()).toBe(before) - }) - - it('renders turn/start and turn/end markers from the session feed', async () => { - const { ctx, out } = await setup() - const agent = makeAgent('main') - ctx.agents.register(agent) - const session = agent.session - emitSessionEvent(ctx, session, { - type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } }, - } as SessionEvent) - expect(out.text()).toContain('[main turn 3] ') - emitSessionEvent(ctx, session, { - type: 'turn/end', seq: 2, time: 0, data: { turn: 3, reason: { kind: 'completed' } }, - }) - expect(out.text()).toContain('\n> ') - }) - - it('renders failure turn/end reasons so a failed turn is not silent', async () => { - const { ctx, out } = await setup() - const session = makeSession('main') - ctx.emit('session/event', session, { - type: 'turn/end', seq: 1, time: 0, - data: { turn: 1, reason: { kind: 'error', step: 1, message: 'fetch failed: connect ECONNREFUSED', code: 'NETWORK' } }, - } as SessionEvent) - expect(out.text()).toContain('[turn failed NETWORK] fetch failed: connect ECONNREFUSED') - ctx.emit('session/event', session, { - type: 'turn/end', seq: 2, time: 0, - data: { turn: 2, reason: { kind: 'error', step: 1, message: 'uncoded failure' } }, - } as SessionEvent) - expect(out.text()).toContain('[turn failed] uncoded failure') - ctx.emit('session/event', session, { - type: 'turn/end', seq: 3, time: 0, data: { turn: 3, reason: { kind: 'aborted', reason: 'user cancelled' } }, - } as SessionEvent) - expect(out.text()).toContain('[turn aborted] user cancelled') - ctx.emit('session/event', session, { - type: 'turn/end', seq: 4, time: 0, data: { turn: 4, reason: { kind: 'aborted' } }, - } as SessionEvent) - expect(out.text()).toContain('[turn aborted]\n> ') - ctx.emit('session/event', session, { - type: 'turn/end', seq: 5, time: 0, data: { turn: 5, reason: { kind: 'rejected', reason: 'policy veto' } }, - } as SessionEvent) - expect(out.text()).toContain('[turn rejected] policy veto') - ctx.emit('session/event', session, { - type: 'turn/end', seq: 6, time: 0, data: { turn: 6, reason: { kind: 'max-tokens' } }, - } as SessionEvent) - expect(out.text()).toContain('[turn hit the output-token limit]') - ctx.emit('session/event', session, { - type: 'turn/end', seq: 7, time: 0, data: { turn: 7, reason: { kind: 'interrupted' } }, - } as SessionEvent) - expect(out.text()).toContain('[turn interrupted by a previous process exit]') - }) - - it('uses the session id as the label for a non-target session', async () => { - const { ctx, out } = await setup() - // No target exists, so the event's durable identity is the label. - emitSessionEvent(ctx, makeSession('orphan'), { - type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, - } as SessionEvent) - expect(out.text()).toContain('[orphan turn 1] ') - }) - - it('uses an agent already registered before the UI installs as its target', async () => { - // The pre-created `main` agent (and any agent surviving an HMR reload of just - // this fiber) fired its `agent/created` before the UI's listener existed, so - // the live listener alone would miss it. Seeding from `ctx.agents.list()` at - // install time preserves the terminal's fixed `[main turn N]` label. - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(UserInteractionService) - const agent = makeAgent('main') - // Durable lineage does not imply runtime child ownership: the stdio app - // may explicitly resume a persisted fork as its one configured agent. - ;(agent.session.header as { parentSession?: string }).parentSession = 'persisted-parent' - ctx.agents.register(agent) // registered BEFORE the UI plugin below - const { runtime, out } = makeRuntime() - await ctx.plugin(Object.assign((inner: Context) => { - createStdioChat(inner, CONFIG, runtime) - }, { inject: ['agents', 'userInteraction'] })) - emitSessionEvent(ctx, agent.session, { - type: 'turn/start', seq: 1, time: 0, data: { turn: 5, trigger: { kind: 'message' } }, - } as SessionEvent) - expect(out.text()).toContain('[main turn 5] ') - }) - - it('buffers input for a lineage-bearing configured agent until its session starts', async () => { - const { ctx, input } = await setup({ welcome: 'hi there', sessionId: 'resumed' }) - input.feed('continue') - await new Promise(resolve => setImmediate(resolve)) - - const unrelated = makeAgent('unrelated') - ctx.agents.register(unrelated) - emitAgentSessionStart(ctx, unrelated, 'startup') - const resumed = makeAgent('resumed') - ;(resumed.session.header as { parentSession?: string }).parentSession = 'persisted-parent' - ctx.agents.register(resumed) - await new Promise(resolve => setImmediate(resolve)) - expect(resumed.sent).toEqual([]) - - emitAgentSessionStart(ctx, resumed, 'resume') - await new Promise(resolve => setImmediate(resolve)) - - expect(unrelated.sent).toEqual([]) - expect(resumed.sent).toEqual([[{ type: 'text', text: 'continue' }]]) - }) - - it('resets dim styling at turn/end if a turn ends mid-reasoning', async () => { - const { ctx, out } = await setup() - const session = makeSession('main') - emitSessionEvent(ctx, session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'mid' })) - emitSessionEvent(ctx, session, { - type: 'turn/end', seq: 1, time: 0, data: { turn: 1, reason: { kind: 'completed' } }, - }) - expect(out.text()).toContain('\x1B[2mmid\x1B[0m') - }) - - it('drops the target object on agent/disposed', async () => { - const { ctx, out } = await setup() - const agent = makeAgent('main') - const dispose = ctx.agents.register(agent) - dispose() - // After disposal the event belongs to a non-target session, so its durable - // identity is rendered directly. - emitSessionEvent(ctx, agent.session, { - type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, - } as SessionEvent) - expect(out.text()).toContain('[main turn 1] ') - }) - - it('keeps the target when a different agent is disposed', async () => { - const { ctx, out } = await setup() - const target = makeAgent('main') - ctx.agents.register(target) - emitAgentDisposed(ctx, makeAgent('other')) - emitSessionEvent(ctx, target.session, { - type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, - } as SessionEvent) - expect(out.text()).toContain('[main turn 1] ') - }) - - it('retargets only the exact identity after loop HMR recreation', async () => { - const { ctx, input } = await setup({ welcome: 'hi there', sessionId: 'main-session-fixed' }) - const oldRoot = makeAgent('main-session-fixed') - const prefixCollision = makeAgent('main-session-unrelated') - const disposeOld = ctx.agents.register(oldRoot) - ctx.agents.register(prefixCollision) - disposeOld() - const replacement = makeAgent('main-session-fixed') - ctx.agents.register(replacement) - input.feed('after hmr') - await new Promise(resolve => setImmediate(resolve)) - expect(replacement.sent).toEqual([]) - emitAgentSessionStart(ctx, replacement, 'resume') - await new Promise(resolve => setImmediate(resolve)) - - expect(prefixCollision.sent).toEqual([]) - expect(replacement.sent).toEqual([[{ type: 'text', text: 'after hmr' }]]) - }) - - it('does not retarget stdin to an unrelated root after the configured agent is disposed', async () => { - const { ctx, input } = await setup() - const unrelated = makeAgent('unrelated') - ctx.agents.register(unrelated) - const configured = makeAgent('main') - const disposeConfigured = registerReady(ctx, configured) - const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) - - disposeConfigured() - input.feed('must not leak') - await new Promise(resolve => setImmediate(resolve)) - - expect(unrelated.sent).toEqual([]) - expect(error).toHaveBeenCalledWith('ui-stdio: main agent is not running') - }) - - it('renders tool/call and tool/result session events', async () => { - const { ctx, out } = await setup() - const session = {} as Session - const callEvent = { - type: 'tool/call', seq: 1, time: 0, - data: { turn: 1, step: 0, callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' }, - } as SessionEvent - emitSessionEvent(ctx, session, callEvent) - expect(out.text()).toContain('[tool call] bash({"command":"ls"})') - - const resultEvent = { - type: 'tool/result', seq: 2, time: 0, - data: { turn: 1, step: 0, callId: 'c1', content: [{ type: 'text', text: 'file.txt' }], isError: false }, - } as SessionEvent - emitSessionEvent(ctx, session, resultEvent) - expect(out.text()).toContain('[tool result] file.txt') - }) - - it('renders one full-fidelity result whether the event feed is live or replayed', async () => { - const { ctx, out } = await setup() - const session = makeSession('main') - const original = { - type: 'tool/result', - seq: 2, - time: 0, - data: { - turn: 1, - step: 1, - callId: 'c1', - content: [{ type: 'text', text: 'full terminal output' }], - isError: false, - meta: { terminal: { output: 'full terminal output' } }, - }, - surfaceOp: 'append', - } as SessionEvent - const replacement = { - ...original, - seq: 3, - data: { - ...original.data, - content: [{ type: 'text', text: '[... tool result middle pruned ...]' }], - }, - surfaceOp: { op: 'replace', start: 2, end: 2 }, - sourceEventSeqs: [2], - } as SessionEvent - - // Stdio consumes the same session/event shape whether a host forwards a - // live append or replays a stored log through the rendering feed. - for (const event of [original, replacement]) ctx.emit('session/event', session, event) - - expect(out.text().match(/\[tool result\]/g)).toHaveLength(1) - expect(out.text()).toContain('full terminal output') - expect(out.text()).not.toContain('tool result middle pruned') - }) - - it('renders a todo/write session event as a glyphed checklist', async () => { - const { ctx, out } = await setup() - const session = {} as Session - emitSessionEvent(ctx, session, { - type: 'todo/write', seq: 1, time: 0, - data: { todos: [ - { content: 'read the code', status: 'completed' }, - { content: 'write the fix', status: 'in_progress' }, - { content: 'run the tests', status: 'pending' }, - ] }, - }) - const text = out.text() - expect(text).toContain('[todos]') - expect(text).toContain('[x] read the code') - expect(text).toContain('[~] write the fix') - expect(text).toContain('[ ] run the tests') - }) - - it('resets dim styling when a todo/write interrupts reasoning', async () => { - const { ctx, out } = await setup() - emitSessionEvent(ctx, {} as Session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'r' })) - emitSessionEvent(ctx, {} as Session, { - type: 'todo/write', seq: 1, time: 0, - data: { todos: [{ content: 'a task', status: 'pending' }] }, - }) - expect(out.text()).toContain('\x1B[2mr\x1B[0m') - }) - - it('resets dim styling when a tool/call interrupts reasoning', async () => { - const { ctx, out } = await setup() - const session = {} as Session - emitSessionEvent(ctx, session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'r' })) - emitSessionEvent(ctx, session, { - type: 'tool/call', seq: 1, time: 0, - data: { turn: 1, step: 0, callId: 'c1', name: 'bash', arguments: '{}' }, - } as SessionEvent) - expect(out.text()).toContain('\x1B[2mr\x1B[0m') - }) - - it('ignores session events it does not render', async () => { - const { ctx, out } = await setup() - const before = out.text() - emitSessionEvent(ctx, {} as Session, { - type: 'user/message', seq: 1, time: 0, - data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, - }) - expect(out.text()).toBe(before) - }) -}) - -describe('createStdioChat input', () => { - it('answers a pending user question instead of sending the line to the agent', async () => { - const { ctx, input, out } = await setup() - const agent = makeAgent('main', 'idle') - ctx.agents.register(agent) - - const answer = ctx.userInteraction.ask({ - questions: [{ - id: 'confirm', - header: 'Confirm', - question: 'Proceed with the edit?', - options: [{ label: 'Yes', description: 'Apply the edit now.' }], - }], - }) - await new Promise(r => setImmediate(r)) - input.feed('Use a smaller change') - - await expect(answer).resolves.toEqual({ answers: [{ id: 'confirm', selected: [], custom: 'Use a smaller change' }] }) - expect(agent.sent).toEqual([]) - expect(out.text()).toContain('[Confirm] Proceed with the edit?') - expect(out.text()).toContain('1. Yes') - expect(out.text()).toContain('Apply the edit now.') - }) - - it('answers a pending user question by numeric option selection', async () => { - const { ctx, input } = await setup() - const answer = ctx.userInteraction.ask({ - questions: [{ - id: 'mode', - question: 'Which mode?', - options: [ - { label: 'Safe' }, - { label: 'Fast' }, - ], - }], - }) - await new Promise(r => setImmediate(r)) - input.feed('2') - - await expect(answer).resolves.toEqual({ - answers: [{ id: 'mode', selected: ['Fast'] }], - }) - }) - - it('renders options in input order and selects by displayed number', async () => { - const { ctx, input, out } = await setup() - const answer = ctx.userInteraction.ask({ - questions: [{ - id: 'topic', - question: 'Which topic?', - options: [ - { label: 'Hobbies' }, - { label: 'Work', description: 'Questions about current projects.' }, - { label: 'Casual', description: 'Easy conversation.' }, - ], - }], - }) - await new Promise(r => setImmediate(r)) - - expect(out.text()).toContain([ - 'Which topic?', - ' 1. Hobbies', - ' 2. Work', - ' Questions about current projects.', - ' 3. Casual', - ' Easy conversation.', - ].join('\n')) - input.feed('3') - - await expect(answer).resolves.toEqual({ - answers: [{ id: 'topic', selected: ['Casual'] }], - }) - }) - - it('answers a multi-select question with multiple numeric selections', async () => { - const { ctx, input } = await setup() - const answer = ctx.userInteraction.ask({ - questions: [{ - id: 'targets', - question: 'What should I update?', - options: [{ label: 'Tests' }, { label: 'Docs' }, { label: 'Code' }], - multiSelect: true, - }], - }) - await new Promise(r => setImmediate(r)) - input.feed('1 1, 3') - - await expect(answer).resolves.toEqual({ - answers: [{ id: 'targets', selected: ['Tests', 'Code'] }], - }) - }) - - it('accepts non-numeric multi-select input as a custom answer', async () => { - const { ctx, input } = await setup() - const answer = ctx.userInteraction.ask({ - questions: [{ - id: 'targets', - question: 'What should I update?', - options: [{ label: 'Tests' }, { label: 'Docs' }], - multiSelect: true, - }], - }) - await new Promise(r => setImmediate(r)) - input.feed('the release notes') - - await expect(answer).resolves.toEqual({ - answers: [{ id: 'targets', selected: [], custom: 'the release notes' }], - }) - }) - - it('asks every question in a batch and returns answers by id', async () => { - const { ctx, input, out } = await setup() - const answer = ctx.userInteraction.ask({ - questions: [ - { id: 'language', question: 'Which language?', options: [{ label: 'Python' }, { label: 'TypeScript' }] }, - { id: 'note', question: 'Any note?' }, - ], - }) - await new Promise(r => setImmediate(r)) - input.feed('2') - await new Promise(r => setImmediate(r)) - expect(out.text()).toContain('\nAny note?\n') - input.feed('ship today') - - await expect(answer).resolves.toEqual({ - answers: [ - { id: 'language', selected: ['TypeScript'] }, - { id: 'note', selected: [], custom: 'ship today' }, - ], - }) - }) - - it('re-prompts when option input is invalid', async () => { - const { ctx, input, out } = await setup() - const answer = ctx.userInteraction.ask({ - questions: [{ - id: 'mode', - question: 'Which mode?', - options: [{ label: 'Safe' }], - multiSelect: true, - }], - }) - await new Promise(r => setImmediate(r)) - input.feed('2') - await new Promise(r => setImmediate(r)) - expect(out.text()).toContain('Please enter one of the option numbers (comma or space separated) or a custom answer.') - input.feed('1') - - await expect(answer).resolves.toEqual({ - answers: [{ id: 'mode', selected: ['Safe'] }], - }) - }) - - it('re-prompts when single-select option input is out of range', async () => { - const { ctx, input, out } = await setup() - const answer = ctx.userInteraction.ask({ - questions: [{ - id: 'mode', - question: 'Which mode?', - options: [{ label: 'Safe' }], - }], - }) - await new Promise(r => setImmediate(r)) - input.feed('2') - await new Promise(r => setImmediate(r)) - expect(out.text()).toContain('Please enter one of the option numbers or a custom answer.') - input.feed('1') - - await expect(answer).resolves.toEqual({ - answers: [{ id: 'mode', selected: ['Safe'] }], - }) - }) - - it('re-prompts when multi-select input contains no option numbers', async () => { - const { ctx, input, out } = await setup() - const answer = ctx.userInteraction.ask({ - questions: [{ - id: 'mode', - question: 'Which mode?', - options: [{ label: 'Safe' }], - multiSelect: true, - }], - }) - await new Promise(r => setImmediate(r)) - input.feed(',') - await new Promise(r => setImmediate(r)) - expect(out.text()).toContain('Please enter one of the option numbers (comma or space separated) or a custom answer.') - input.feed('1') - - await expect(answer).resolves.toEqual({ - answers: [{ id: 'mode', selected: ['Safe'] }], - }) - }) - - it('re-prompts when an option question receives an empty answer', async () => { - const { ctx, input, out } = await setup() - const answer = ctx.userInteraction.ask({ - questions: [{ - id: 'mode', - question: 'Which mode?', - options: [{ label: 'Safe' }], - }], - }) - await new Promise(r => setImmediate(r)) - input.feed('') - await new Promise(r => setImmediate(r)) - expect(out.text()).toContain('Please enter one of the option numbers or a custom answer.') - input.feed('1') - - await expect(answer).resolves.toEqual({ - answers: [{ id: 'mode', selected: ['Safe'] }], - }) - }) - - it('re-prompts when a question receives an empty answer', async () => { - const { ctx, input, out } = await setup() - const answer = ctx.userInteraction.ask({ questions: [{ id: 'path', question: 'What should I use?' }] }) - await new Promise(r => setImmediate(r)) - input.feed('') - await new Promise(r => setImmediate(r)) - expect(out.text()).toContain('Please enter an answer.') - input.feed('Use defaults') - - await expect(answer).resolves.toEqual({ answers: [{ id: 'path', selected: [], custom: 'Use defaults' }] }) - }) - - it('rejects an active question when its signal aborts', async () => { - const { ctx } = await setup() - const controller = new AbortController() - const answer = ctx.userInteraction.ask({ questions: [{ id: 'continue', question: 'Continue?' }], signal: controller.signal }) - const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) - await new Promise(r => setImmediate(r)) - - controller.abort() - - await rejected - }) - - it('continues to the next queued question when the active question aborts', async () => { - const { ctx, input, out } = await setup() - const controller = new AbortController() - const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }], signal: controller.signal }) - const firstRejected = expect(first).rejects.toMatchObject({ code: 'ASK_ABORTED' }) - const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }] }) - await new Promise(r => setImmediate(r)) - - controller.abort() - await firstRejected - await new Promise(r => setImmediate(r)) - expect(out.text()).toContain('\nSecond?\n') - input.feed('second answer') - - await expect(second).resolves.toEqual({ answers: [{ id: 'second', selected: [], custom: 'second answer' }] }) - }) - - it('skips a queued question whose signal aborted before it became active', async () => { - const { ctx, input, out } = await setup() - const controller = new AbortController() - const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }] }) - const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }], signal: controller.signal }) - await new Promise(r => setImmediate(r)) - - controller.abort() - - await expect(Promise.race([ - second.then( - () => 'resolved', - (error: unknown) => (error as { code?: string }).code, - ), - new Promise((resolve) => { setImmediate(() => { resolve('pending') }) }), - ])).resolves.toBe('ASK_ABORTED') - expect(out.text()).not.toContain('\nSecond?\n') - input.feed('first answer') - await expect(first).resolves.toEqual({ answers: [{ id: 'first', selected: [], custom: 'first answer' }] }) - }) - - it('removes an aborted queued question without promoting later queued work early', async () => { - const { ctx, input, out } = await setup() - const controller = new AbortController() - const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }] }) - const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }], signal: controller.signal }) - const third = ctx.userInteraction.ask({ questions: [{ id: 'third', question: 'Third?' }] }) - await new Promise(r => setImmediate(r)) - - controller.abort() - - await expect(second).rejects.toMatchObject({ code: 'ASK_ABORTED' }) - expect(out.text()).toContain('\nFirst?\n') - expect(out.text()).not.toContain('\nSecond?\n') - expect(out.text()).not.toContain('\nThird?\n') - input.feed('first answer') - await new Promise(r => setImmediate(r)) - - expect(out.text()).toContain('\nThird?\n') - input.feed('third answer') - - await expect(first).resolves.toEqual({ answers: [{ id: 'first', selected: [], custom: 'first answer' }] }) - await expect(third).resolves.toEqual({ answers: [{ id: 'third', selected: [], custom: 'third answer' }] }) - }) - - it('rejects active and queued questions when the UI is disposed', async () => { - const { ctx, fiber } = await setup() - const active = ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }] }) - const queued = ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }] }) - const activeRejected = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' }) - const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' }) - await new Promise(r => setImmediate(r)) - - await fiber.dispose() - - await activeRejected - await queuedRejected - }) - - it('rejects active and queued questions when stdin closes before the user answers', async () => { - const { ctx, input, exit } = await setup() - const active = ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }] }) - const queued = ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }] }) - const activeRejected = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' }) - const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' }) - await new Promise(r => setImmediate(r)) - - input.finish() - await new Promise(r => setImmediate(r)) - - await activeRejected - await queuedRejected - expect(exit).not.toHaveBeenCalled() - }) - - it('rejects new questions immediately after stdin has closed', async () => { - const { ctx, input, out } = await setup() - input.finish() - await new Promise(r => setImmediate(r)) - const before = out.text() - - const answer = ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Too late?' }] }) - - await expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) - expect(out.text()).toBe(before) - }) - - it('sends a typed line to an idle agent', async () => { - const { ctx, input } = await setup() - const agent = makeAgent('main', 'idle') - registerReady(ctx, agent) - input.feed('do a thing') - await new Promise(r => setImmediate(r)) - expect(agent.sent).toEqual([[{ type: 'text', text: 'do a thing' }]]) - expect(agent.steered).toEqual([]) - }) - - it('steers a typed line into a running agent', async () => { - const { ctx, input } = await setup() - const agent = makeAgent('main', 'running') - registerReady(ctx, agent) - input.feed('steer me') - await new Promise(r => setImmediate(r)) - expect(agent.steered).toEqual([[{ type: 'text', text: 'steer me' }]]) - expect(agent.sent).toEqual([]) - }) - - it('ignores blank lines', async () => { - const { ctx, input } = await setup() - const agent = makeAgent('main') - ctx.agents.register(agent) - input.feed(' ') - await new Promise(r => setImmediate(r)) - expect(agent.sent).toEqual([]) - }) - - it('buffers a line until the initial target session starts', async () => { - const { ctx, input } = await setup() - const spy = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) - input.feed('nobody home') - await new Promise(r => setImmediate(r)) - expect(spy).not.toHaveBeenCalled() - - const agent = makeAgent('main') - ctx.agents.register(agent) - await new Promise(r => setImmediate(r)) - expect(agent.sent).toEqual([]) - emitAgentSessionStart(ctx, agent, 'startup') - await new Promise(r => setImmediate(r)) - expect(agent.sent).toEqual([[{ type: 'text', text: 'nobody home' }]]) - }) - - it('drops later input after the configured startup fails', async () => { - const { ctx, input } = await setup() - const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) - const failure = unrenderableFailure() - ctx.emit('agent-loop/config-start-failed', SessionId('main'), failure) - - input.feed('cannot run') - await new Promise(r => setImmediate(r)) - - expect(error).toHaveBeenCalledWith( - 'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ', - ) - }) - - it('ignores a stale config-start failure after the exact target is ready', async () => { - const { ctx, input } = await setup() - const agent = makeAgent('main') - registerReady(ctx, agent) - ctx.emit('agent-loop/config-start-failed', SessionId('main'), new Error('stale')) - - input.feed('still live') - await new Promise(r => setImmediate(r)) - - expect(agent.sent).toEqual([[{ type: 'text', text: 'still live' }]]) - }) - - it('drives the exact app-configured resumed session', async () => { - const { ctx, input } = await setup({ welcome: 'w', sessionId: 'worker' }) - const agent = makeAgent('worker') - registerReady(ctx, agent, 'resume') - input.feed('hi') - await new Promise(r => setImmediate(r)) - expect(agent.sent).toHaveLength(1) - }) - -}) - -describe('createStdioChat EOF exit', () => { - it('exits immediately on EOF when no work was submitted', async () => { - const { input, exit } = await setup() - input.finish() - await flushExit() - expect(exit).toHaveBeenCalledWith(0) - }) - - it('waits for the agent to settle idle after running before exiting', async () => { - const { ctx, input, exit } = await setup() - const agent = makeAgent('main', 'idle') - registerReady(ctx, agent) - input.feed('work') - await new Promise(r => setImmediate(r)) - input.finish() - await new Promise(r => setImmediate(r)) - // Work submitted but no 'running' observed yet — must NOT exit. - expect(exit).not.toHaveBeenCalled() - // The turn starts, then settles. - emitAgentStatus(ctx, agent, 'running') - ;(agent as { status: AgentStatus }).status = 'idle' - emitAgentStatus(ctx, agent, 'idle') - await flushExit() - expect(exit).toHaveBeenCalledWith(0) - }) - - it('keeps piped EOF pending until buffered startup input runs', async () => { - const { ctx, input, exit } = await setup() - input.feed('work') - input.finish() - await flushExit() - expect(exit).not.toHaveBeenCalled() - - const agent = makeAgent('main', 'idle') - ctx.agents.register(agent) - await new Promise(r => setImmediate(r)) - expect(agent.sent).toEqual([]) - emitAgentSessionStart(ctx, agent, 'startup') - await new Promise(r => setImmediate(r)) - expect(agent.sent).toEqual([[{ type: 'text', text: 'work' }]]) - emitAgentStatus(ctx, agent, 'running') - ;(agent as { status: AgentStatus }).status = 'idle' - emitAgentStatus(ctx, agent, 'idle') - await flushExit() - expect(exit).toHaveBeenCalledWith(0) - }) - - it('drains buffered piped input and exits when configured startup fails', async () => { - const { ctx, input, exit } = await setup() - const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) - input.feed('work') - input.finish() - await new Promise(r => setImmediate(r)) - ctx.emit('agent-loop/config-start-failed', SessionId('other'), new Error('unrelated')) - await flushExit() - expect(exit).not.toHaveBeenCalled() - - ctx.emit('agent-loop/config-start-failed', SessionId('main'), unrenderableFailure()) - await flushExit() - - expect(error).toHaveBeenCalledWith( - 'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ', - ) - expect(exit).toHaveBeenCalledWith(0) - }) - - it('schedules the exit only once when idle fires repeatedly', async () => { - const { ctx, input, exit } = await setup() - const agent = makeAgent('main', 'running') - registerReady(ctx, agent) - input.feed('work') - await new Promise(r => setImmediate(r)) - emitAgentStatus(ctx, agent, 'running') // sawRunning = true - input.finish() - await new Promise(r => setImmediate(r)) // let readline 'close' set stdinClosed - ;(agent as { status: AgentStatus }).status = 'idle' - // Two idle signals while stdin is already closed: the first arms the timer, - // the second must hit the already-scheduled guard, not arm a second. - emitAgentStatus(ctx, agent, 'idle') - emitAgentStatus(ctx, agent, 'idle') - await flushExit() - expect(exit).toHaveBeenCalledTimes(1) - }) - - it('does not exit on an idle transition for a different agent', async () => { - const { ctx, input, exit } = await setup() - const agent = makeAgent('main', 'idle') - registerReady(ctx, agent) - input.feed('work') - await new Promise(r => setImmediate(r)) - input.finish() - const other = makeAgent('other') - emitAgentStatus(ctx, other, 'running') - emitAgentStatus(ctx, other, 'idle') - await flushExit() - expect(exit).not.toHaveBeenCalled() - }) - - it('does not exit while a turn is still running at EOF', async () => { - const { ctx, input, exit } = await setup() - const agent = makeAgent('main', 'idle') - registerReady(ctx, agent) - input.feed('work') - await new Promise(r => setImmediate(r)) - emitAgentStatus(ctx, agent, 'running') - ;(agent as { status: AgentStatus }).status = 'running' - input.finish() - // sawRunning is true, but the agent is still running — the idle gate holds. - emitAgentStatus(ctx, agent, 'idle') // a stale/duplicate signal while status stays 'running' - await flushExit() - expect(exit).not.toHaveBeenCalled() - }) -}) - -describe('createStdioChat disposal (HMR safety)', () => { - it('never exits the process when EOF arrives after fiber dispose', async () => { - const { fiber, input, exit } = await setup() - await fiber.dispose() - // A late EOF after disposal (reader.close() also fires 'close') must not exit. - input.finish() - await flushExit() - expect(exit).not.toHaveBeenCalled() - }) - - it('cancels a scheduled exit if disposed within the flush window', async () => { - const { fiber, input, exit } = await setup() - // EOF with no work submitted schedules the 200ms flush-then-exit timer. - input.finish() - await new Promise(r => setImmediate(r)) - expect(exit).not.toHaveBeenCalled() // not yet — still inside the window - // Dispose BEFORE the timer fires: the tracked handle must be cleared. - await fiber.dispose() - await flushExit() - expect(exit).not.toHaveBeenCalled() - }) - - it('stops handling input after dispose', async () => { - const { ctx, fiber, input } = await setup() - const agent = makeAgent('main') - ctx.agents.register(agent) - await fiber.dispose() - // The readline interface is closed on dispose; a late line reaches no handler. - input.feed('too late') - await new Promise(r => setImmediate(r)) - expect(agent.sent).toEqual([]) - }) - - it('removes the agent/status listener on dispose', async () => { - const { ctx, fiber, input, exit } = await setup() - const agent = makeAgent('main', 'idle') - registerReady(ctx, agent) - input.feed('work') - await new Promise(r => setImmediate(r)) - await fiber.dispose() - // After dispose, status transitions must neither throw nor schedule an exit - // (the listener and the EOF-exit path are both torn down). - expect(() => { - emitAgentStatus(ctx, agent, 'running') - emitAgentStatus(ctx, agent, 'idle') - }).not.toThrow() - await flushExit() - expect(exit).not.toHaveBeenCalled() - }) -}) diff --git a/packages/ui/stdio/tsconfig.json b/packages/ui/stdio/tsconfig.json deleted file mode 100644 index c37d7ed522..0000000000 --- a/packages/ui/stdio/tsconfig.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../../vendor/schemastery" - }, - { - "path": "../../core/agent" - }, - { - "path": "../../core/agent-loop" - }, - { - "path": "../../core/session" - }, - { - "path": "../../llm/llm" - }, - { - "path": "../user-interaction" - }, - { - "path": "../../support/invariants" - } - ] -} diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 6d2c7858e4..f44644c430 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-tui -The interactive terminal front door for DeepSeek Harness agents, built on [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui). It requires stdin and stdout TTYs; scripts and Loader pipes should compose [`@deepseek-ai/dsh-stdio`](../stdio/README.md) instead. +The interactive terminal front door for DeepSeek Harness agents, built on [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui). It requires stdin and stdout TTYs; scripts and Loader pipes should use the headless [`@deepseek-ai/dsh-cli-demo`](../../examples/cli-demo/README.md) app instead. The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision; the [terminal-state snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy. @@ -77,4 +77,4 @@ Append-only; newly visible content follows the reusable request prefix and does - **One configured session owns the transcript and editor** — questions from other agents can still use the shared overlay provider, but session rendering and prompt input remain bound to `sessionId`. - **Tool cards are text terminal presentations** — terminal, diff, and generic cards use tool-owned titles/content, but session content currently has no image block for inline image rendering. -- **Non-TTY operation is intentionally unsupported** — app bundles that need automation must select `dsh-stdio` before mounting this plugin rather than expecting an internal fallback. +- **Non-TTY operation is intentionally unsupported** — automation must use the headless app rather than expecting an internal fallback. diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index ae0c480dde..35721669fb 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1336,10 +1336,10 @@ export function mountTui(ctx: Context, config: Config, runtime: TuiRuntime): voi /** Cordis entry point using the process terminal; explicit TUI composition requires a TTY pair. */ /* v8 ignore start -- production process wiring; fake-terminal tests cover mountTui/createTuiChat, - and the repl-agent PTY smoke covers the real entry */ + and the tui-agent PTY smoke covers the real entry */ export function apply(ctx: Context, config: Config): void { if (!process.stdin.isTTY || !process.stdout.isTTY) { - throw new Error('ui-tui: both stdin and stdout must be TTYs; use @deepseek-ai/dsh-stdio for pipes') + throw new Error('ui-tui: both stdin and stdout must be TTYs; use @deepseek-ai/dsh-cli-demo for non-interactive runs') } mountTui(ctx, config, { terminal: new ProcessTerminal(), diff --git a/packages/ui/user-interaction/README.md b/packages/ui/user-interaction/README.md index 2ddf261c3a..c026ff0395 100644 --- a/packages/ui/user-interaction/README.md +++ b/packages/ui/user-interaction/README.md @@ -21,7 +21,7 @@ When an answer includes `custom`, `selected` is empty; custom text is an overrid ## Role -This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI front doors such as the interactive `dsh-tui`, line-oriented `dsh-stdio`, and structured `dsh-acp` channels provide the provider. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop. +This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; the interactive `dsh-tui` and structured `dsh-acp` front doors provide the provider. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop. ## Model Experience diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93c1caf185..9067fc39b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,9 @@ importers: '@deepseek-ai/dsh-agent-spine-demo': specifier: workspace:* version: link:../packages/examples/agent-spine-demo + '@deepseek-ai/dsh-app-boot': + specifier: workspace:* + version: link:../packages/ui/app-boot '@deepseek-ai/dsh-bash-local': specifier: workspace:* version: link:../packages/bash/bash-local @@ -170,9 +173,6 @@ importers: '@deepseek-ai/dsh-spill-policy': specifier: workspace:* version: link:../packages/spill/spill-policy - '@deepseek-ai/dsh-stdio-demo': - specifier: workspace:* - version: link:../packages/examples/stdio-demo '@deepseek-ai/dsh-subagent': specifier: workspace:* version: link:../packages/subagent/subagent @@ -212,6 +212,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../packages/core/tools + '@deepseek-ai/dsh-tui-demo': + specifier: workspace:* + version: link:../packages/examples/tui-demo '@deepseek-ai/dsh-user-approval': specifier: workspace:* version: link:../packages/ui/user-approval @@ -895,7 +898,7 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/examples/stdio-demo: + packages/examples/tui-demo: devDependencies: '@cordisjs/plugin-include': specifier: workspace:^ @@ -903,9 +906,6 @@ importers: '@cordisjs/plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader - '@cordisjs/plugin-logger-console': - specifier: workspace:^ - version: link:../../../vendor/logger-console '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -930,12 +930,6 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl - '@deepseek-ai/dsh-stdio': - specifier: workspace:^ - version: link:../../ui/stdio - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt '@deepseek-ai/dsh-tool-ask-user': specifier: workspace:^ version: link:../../ui/tool-ask-user @@ -2331,37 +2325,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/ui/stdio: - dependencies: - schemastery: - specifier: ^3.18.0 - version: 3.18.0 - devDependencies: - '@cordisjs/plugin-loader': - specifier: workspace:^ - version: link:../../../vendor/loader - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../../core/agent-loop - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-user-interaction': - specifier: workspace:^ - version: link:../user-interaction - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) - packages/ui/tool-ask-user: devDependencies: '@deepseek-ai/dsh-agent': diff --git a/scripts/demo-code-mode.mjs b/scripts/demo-code-mode.mjs index 43bff2d4ba..273e6e1b38 100644 --- a/scripts/demo-code-mode.mjs +++ b/scripts/demo-code-mode.mjs @@ -1,23 +1,20 @@ /** - * Boot the REPL, TUI, or ACP Code Mode overlay, defaulting to REPL. Each overlay + * Boot the TUI or ACP Code Mode overlay, defaulting to TUI. Each overlay * includes its base example, selects Code Mode, and adds the worker runtime. * All require a DeepSeek API key; unsupported arguments fail with usage. */ import { spawn } from 'node:child_process' -// Each UI's node invocation, verbatim what its base demo script runs plus -// the overlay config (the stdio bin keeps --expose-internals for the cordis -// Loader's HMR path). +// Each UI's node invocation matches its base demo script plus the overlay config. const UIS = new Map([ - ['repl', ['--expose-internals', '--import', 'tsx', 'packages/examples/stdio-demo/src/bin.ts', 'examples/repl-agent/code-mode.cordis.yml']], - ['tui', ['--expose-internals', '--import', 'tsx', 'packages/examples/stdio-demo/src/bin.ts', 'examples/tui-agent/code-mode.cordis.yml']], + ['tui', ['--expose-internals', '--import', 'tsx', 'packages/examples/tui-demo/src/bin.ts', 'examples/tui-agent/code-mode.cordis.yml']], ['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']], ]) -const ui = process.argv[2] ?? 'repl' +const ui = process.argv[2] ?? 'tui' const args = UIS.get(ui) if (!args || process.argv.length > 3) { - console.error('usage: pnpm run demo:code-mode [repl|tui|acp]') + console.error('usage: pnpm run demo:code-mode [tui|acp]') process.exit(2) } diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 994f5bf427..d5a035e0d7 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -156,8 +156,8 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'user-interaction', title: 'Human question/answer seam', mode: 'seam', - implementations: ['stdio-demo', 'acp'], - consumers: ['tool-ask-user', 'stdio-demo', 'acp'], + implementations: ['tui', 'acp'], + consumers: ['tool-ask-user', 'tui', 'acp'], note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.', }, { @@ -174,7 +174,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'agent', title: 'Agent service', mode: 'core', - consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess', 'stdio-demo'], + consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess', 'tui-demo'], note: 'Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation.', }, { @@ -443,29 +443,13 @@ function stripYamlScalar(value: string): string { } const APP_EXAMPLES = [ - { - id: 'echo', - rel: 'examples/echo-agent/composition.md', - title: 'Echo Agent App Composition', - label: 'examples/echo-agent', - config: 'examples/echo-agent/cordis.yml', - summary: 'The echo demo swaps in a local mock LLM and teaching echo tool, then loads the stdio app package for the shared spine and terminal front door.', - }, - { - id: 'repl', - rel: 'examples/repl-agent/composition.md', - title: 'REPL Agent App Composition', - label: 'examples/repl-agent', - config: 'examples/repl-agent/cordis.yml', - summary: 'The REPL agent demo adds the real DeepSeek adapter, filesystem tools, todo_write, tool-result pruning, compaction, and both subagent transports on top of the stdio app package.', - }, { id: 'tui', rel: 'examples/tui-agent/composition.md', title: 'TUI Agent App Composition', label: 'examples/tui-agent', config: 'examples/tui-agent/cordis.yml', - summary: 'The TUI agent reuses the repl-agent backend and tool composition while fixing the shared terminal app to the full-screen dsh-tui front door.', + summary: 'The TUI agent combines the real DeepSeek adapter, coding tools, compaction, subagents, and workflows with the full-screen terminal app package.', }, { id: 'headless', @@ -495,18 +479,13 @@ const APP_EXAMPLES = [ type AppExample = typeof APP_EXAMPLES[number] -function renderAppExpansion(lines: string[], appNode: string, pluginName: string, exampleId: string): void { +function renderAppExpansion(lines: string[], appNode: string, pluginName: string): void { const agentCore = nodeId('bundle', 'agent_core') const jsonl = nodeId('bundle', 'jsonl') lines.push(` ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-spine-demo"]`) lines.push(` ${appNode} --> ${jsonl}["@deepseek-ai/dsh-session-persistence-jsonl"]`) - if (pluginName === '@deepseek-ai/dsh-stdio-demo') { - const frontDoor = exampleId === 'tui' - ? '@deepseek-ai/dsh-tui
pre-created main agent' - : exampleId === 'repl' - ? '@deepseek-ai/dsh-stdio
pre-created main agent' - : 'dsh-tui (TTY) / dsh-stdio (pipes)
pre-created main agent' - lines.push(` ${appNode} --> ${nodeId('frontdoor', 'stdio')}["${frontDoor}"]`) + if (pluginName === '@deepseek-ai/dsh-tui-demo') { + lines.push(` ${appNode} --> ${nodeId('frontdoor', 'tui')}["@deepseek-ai/dsh-tui
pre-created main agent"]`) } else if (pluginName === '@deepseek-ai/dsh-cli-demo') { lines.push(` ${appNode} --> ${nodeId('frontdoor', 'cli')}["one-shot driver
format-pure stdout
fresh top-level agent"]`) } else if (pluginName === '@deepseek-ai/dsh-acp-demo') { @@ -535,8 +514,8 @@ function renderAppComposition(example: AppExample): string { const pluginNode = nodeId(`plugin_${example.id}`, plugin.id) lines.push(` ${pluginNode}["${escLabel(plugin.id)}
${escLabel(plugin.name)}"]`) lines.push(` cfg --> ${pluginNode}`) - if (plugin.name === '@deepseek-ai/dsh-stdio-demo' || plugin.name === '@deepseek-ai/dsh-cli-demo' || plugin.name === '@deepseek-ai/dsh-acp-demo') { - renderAppExpansion(lines, pluginNode, plugin.name, example.id) + if (plugin.name === '@deepseek-ai/dsh-tui-demo' || plugin.name === '@deepseek-ai/dsh-cli-demo' || plugin.name === '@deepseek-ai/dsh-acp-demo') { + renderAppExpansion(lines, pluginNode, plugin.name) } } lines.push( @@ -1034,8 +1013,6 @@ function renderDocs(): GraphDoc[] { function renderIndex(docs: GraphDoc[]): string { const labels: Record = { 'docs/capability-seams.md': 'capability seams and core services', - 'examples/echo-agent/composition.md': 'echo-agent app composition', - 'examples/repl-agent/composition.md': 'repl-agent app composition', 'examples/headless-agent/composition.md': 'headless-agent app composition', 'examples/tui-agent/composition.md': 'tui-agent app composition', 'examples/cordis-agent/composition.md': 'cordis-agent app composition', @@ -1047,8 +1024,6 @@ function renderIndex(docs: GraphDoc[]): string { } const modes: Record = { 'docs/capability-seams.md': 'hybrid generated', - 'examples/echo-agent/composition.md': 'hybrid generated', - 'examples/repl-agent/composition.md': 'hybrid generated', 'examples/headless-agent/composition.md': 'hybrid generated', 'examples/tui-agent/composition.md': 'hybrid generated', 'examples/cordis-agent/composition.md': 'hybrid generated', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 2bc9b78e23..4840d81dbc 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -252,7 +252,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolSubagent, { provider: 'mock' }) }, note: - 'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.', + 'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.', }, { pkg: '@deepseek-ai/dsh-tool-tasks', diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 87201f8d85..b51e47cedc 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -5,9 +5,8 @@ * independent commands can overlap and which commands wait for built artifacts. */ import { spawn } from 'node:child_process' -import { readdir, rm } from 'node:fs/promises' import { availableParallelism } from 'node:os' -import { join, resolve } from 'node:path' +import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' type Mode = @@ -216,7 +215,6 @@ function ciPrimaryGates(): Gate[] { pnpmScript('duplication', 'duplication'), coverageGate(), snapshotGate(), - demoSmokeGate({ needs: ['lint'] }), ...docSyncLeafGates(), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), pnpmScript('knip', 'knip'), @@ -236,18 +234,12 @@ function ciStaticGates(): Gate[] { pnpmScript('constraints', 'constraints'), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), - ...staticDemoSmokeGates(), ...docSyncLeafGates(), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), pnpmScript('knip', 'knip'), ] } -function staticDemoSmokeGates(): Gate[] { - // Native Windows session persistence is outside the gates-only support scope. - return process.platform === 'win32' ? [] : [demoSmokeGate()] -} - function ciArtifactGates(): Gate[] { return [ pnpmScript('build', 'build'), @@ -361,50 +353,14 @@ function docSyncLeafGates(options: { ] } -function demoSmokeGate(options: { needs?: string[] } = {}): Gate { - const dependencyOptions = options.needs === undefined ? {} : { needs: options.needs } - return { - id: 'demo-smoke', - label: 'demo smoke', - displayCommand: 'pnpm run demo:echo', - ...pnpmInvocation(['run', 'demo:echo']), - input: 'echo ci smoke\n', - ...dependencyOptions, - verify: async (result) => { - const output = result.stdout + result.stderr - const sessionsRoot = join(root, '.sessions') - try { - if (!output.includes('[tool call] echo({"text":"ci smoke"})')) { - throw new Error('demo smoke did not show the echo tool call.') - } - if (!output.includes('[tool result] ECHO: CI SMOKE')) { - throw new Error('demo smoke did not show the echo tool result.') - } - const buckets = await readdir(sessionsRoot, { withFileTypes: true }) - let found = false - for (const bucket of buckets) { - if (!bucket.isDirectory() || !bucket.name.startsWith('cwd-')) continue - const entries = await readdir(join(sessionsRoot, bucket.name)) - if (entries.some(entry => /^main-session-.+\.jsonl\.zstd$/.test(entry))) { - found = true - break - } - } - if (!found) throw new Error('demo smoke did not create a main-session JSONL log in a cwd bucket.') - } finally { - await rm(sessionsRoot, { recursive: true, force: true }) - } - }, - } -} - function builtBinSmokeGate(): Gate { return pnpmExec('built-bin-smoke', [ 'vitest', 'run', '--config', 'vitest.e2e.config.ts', - 'packages/examples/stdio-demo/tests/built-bin.e2e.ts', + 'examples/headless-agent/tests/keyless-smoke.e2e.ts', + 'examples/tui-agent/tests/tui-keyless-smoke.e2e.ts', 'packages/examples/cli-demo/tests/built-bin.e2e.ts', 'packages/examples/acp-demo/tests/built-bin.e2e.ts', 'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts', @@ -416,6 +372,7 @@ function builtBinSmokeGate(): Gate { ], { label: 'built-bin smoke', needs: ['build'], + env: { DSH_EXAMPLE_MODE: 'lib' }, }) } diff --git a/skills/create-dsh-sdk-project/SKILL.md b/skills/create-dsh-sdk-project/SKILL.md index 5b984f3b86..d05cae182a 100644 --- a/skills/create-dsh-sdk-project/SKILL.md +++ b/skills/create-dsh-sdk-project/SKILL.md @@ -30,7 +30,7 @@ block. "provider": "deepseek", "apiKey": "", "model": "deepseek-v4-flash", - "interface": "stdio", + "interface": "tui", "pm": "npm", "install": false, "features": [ diff --git a/tsconfig.build.json b/tsconfig.build.json index 9e87410441..189f23fdc4 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -76,8 +76,7 @@ { "path": "./packages/ui/jsonrpc" }, { "path": "./packages/examples/jsonrpc-demo" }, { "path": "./packages/ui/tui" }, - { "path": "./packages/ui/stdio" }, - { "path": "./packages/examples/stdio-demo" }, + { "path": "./packages/examples/tui-demo" }, { "path": "./packages/support/llm-replay" }, { "path": "./packages/support/acp-snapshot" }, { "path": "./packages/support/loader-smoke" }, diff --git a/tsconfig.json b/tsconfig.json index 1fbd7e362c..29474f33eb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -89,8 +89,7 @@ { "path": "./packages/ui/jsonrpc" }, { "path": "./packages/examples/jsonrpc-demo" }, { "path": "./packages/ui/tui" }, - { "path": "./packages/ui/stdio" }, - { "path": "./packages/examples/stdio-demo" }, + { "path": "./packages/examples/tui-demo" }, { "path": "./packages/support/llm-replay" }, { "path": "./packages/support/acp-snapshot" }, { "path": "./packages/support/loader-smoke" },