Merge branch 'master' into fix/tui-color-scheme-v2
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -114,7 +114,7 @@ When optional consumers are loaded, ACP form answers become the exact JSON shape
|
||||
|
||||
#### Token effect
|
||||
|
||||
Answer, error, and denial text enters context only through the owning tool result; presentation metadata adds zero model tokens.
|
||||
Answer, error, and denial text enters context only through the owning tool result; presentation metadata adds zero model tokens. A replacement `tool/result` still changes the model-facing session surface, but live and replayed ACP feeds ignore it as an execution update so the original terminal or diff completion is not overwritten.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
|
||||
| `agent_thought_chunk` | S | ✅ | ✅ | ✅ | From `assistant/chunk` reasoning-delta. |
|
||||
| `user_message_chunk` | S | ✅ | ✅ | ✅ | Emitted during `session/load` replay to reconstruct the user side. |
|
||||
| `tool_call` | S | ✅ | ✅ | ✅ | Tool-owned presentation (`presentCall`); see [§5](#5-tool-call-rendering). |
|
||||
| `tool_call_update` | S | ✅ | ✅ | ✅ | From `tool/result` via `presentResult`. |
|
||||
| `tool_call_update` | S | ✅ | ✅ | ✅ | From appended `tool/result` via `presentResult`; replacement results rewrite model context and do not duplicate or overwrite execution presentation. |
|
||||
| `plan` | S | ❌ | ✅ | ✅ | No agent plan emitted. Both adapters emit real plan entries (Codex's `CodexEventHandler.updatePlan` maps `turn/plan/updated` → `{ sessionUpdate: 'plan', entries }`). |
|
||||
| `available_commands_update` | S | ❌ | ✅ | ✅ | No slash commands advertised. |
|
||||
| `current_mode_update` | S | ❌ | ✅ | ✅ | No session modes. |
|
||||
|
||||
@@ -827,10 +827,11 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// session/cancel maps to the queue-aware agent.cancel(reason): it aborts
|
||||
// a RUNNING step, clears the queued + steering FIFOs, and drops a
|
||||
// turn that is about to start (the pre-step window) — so a queued-but-
|
||||
// not-yet-started prompt never runs, and a prompt accepted right after
|
||||
// cannot be batched into the cancelled turn. Scoped to THIS session's
|
||||
// not-yet-started prompt never runs, while a prompt accepted afterward
|
||||
// remains a separate queued turn. Scoped to THIS session's
|
||||
// agent — a cancel in one session never touches another's stream or
|
||||
// pending prompt (RFC 011 isolation). We ALSO settle the in-flight prompt
|
||||
// pending prompt (multi-session isolation).
|
||||
// We ALSO settle the in-flight prompt
|
||||
// as cancelled directly here: do NOT rely on the resulting turn/end to
|
||||
// settle it, because cancel() may drop the turn before any turn/end is
|
||||
// emitted, and removing this direct settle would move the RPC's
|
||||
@@ -1039,7 +1040,8 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void {
|
||||
* loaded transcript reconstructs the USER side of each turn without echoing
|
||||
* a live `session/prompt` back to the client
|
||||
* - `tool/call` → `tool_call` (pending)
|
||||
* - `tool/result` → `tool_call_update` (completed/failed)
|
||||
* - appended `tool/result` → `tool_call_update` (completed/failed)
|
||||
* - replacement `tool/result` → no update (context rewrite, not execution)
|
||||
*
|
||||
* Tool-call presentation (title/kind/rawInput, and the completed-state content)
|
||||
* is owned by each TOOL via `presentCall`/`presentResult` — the bridge never
|
||||
@@ -1100,6 +1102,10 @@ export function streamSessionEventUpdate(
|
||||
return
|
||||
}
|
||||
case 'tool/result': {
|
||||
// Replacements (for example model-free pruning) are transcript rewrites,
|
||||
// not repeated tool executions. Re-presenting one would consume no
|
||||
// pending call and could clobber the original terminal/diff completion.
|
||||
if (event.surfaceOp !== undefined && event.surfaceOp !== 'append') return
|
||||
const view = presenter.result(event.data.callId, event.data.content, event.data.isError, event.data.meta)
|
||||
notify({ sessionId, update: toolResultUpdate(event.data.callId, view, event.data.isError, terminal) })
|
||||
return
|
||||
|
||||
@@ -223,7 +223,8 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
|
||||
it('per-session AgentHandle dispose leaves sibling agents untouched', async () => {
|
||||
// The factory returns a per-agent AgentHandle whose dispose() tears down
|
||||
// EXACTLY that agent + its session — RFC 011 isolation. Create two agents
|
||||
// EXACTLY that agent + its session — the registry's per-handle isolation
|
||||
// contract. Create two agents
|
||||
// directly through the registry factory (the same path the ACP bridge uses),
|
||||
// dispose one handle, and assert the other survives, registered and
|
||||
// queryable, with its session still in the store.
|
||||
|
||||
@@ -161,6 +161,53 @@ describe('acp bridge — session/load replay', () => {
|
||||
expect(meta.terminal_exit?.exit_code).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps one terminal completion live and on replay when a pruning replacement is logged', async () => {
|
||||
live = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withBash: true,
|
||||
script: [toolCallResponse('c1', 'bash', { command: 'echo full', description: 'Print full output' }), textResponse('done')],
|
||||
})
|
||||
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
|
||||
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'run it' }] })
|
||||
|
||||
const session = live.ctx.agents.get(SessionId(sessionId))!.session
|
||||
const original = session.events.find(event => event.type === 'tool/result')
|
||||
if (original?.type !== 'tool/result') throw new Error('expected original tool/result')
|
||||
const liveCompletions = () => live!.updates.filter(update =>
|
||||
update.sessionUpdate === 'tool_call_update' && update.toolCallId === 'c1')
|
||||
expect(liveCompletions()).toHaveLength(1)
|
||||
expect((liveCompletions()[0] as { _meta?: { terminal_output?: { data: string } } })._meta?.terminal_output?.data)
|
||||
.toBe('full\n')
|
||||
|
||||
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('tool/result', {
|
||||
...original.data,
|
||||
content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
|
||||
sourceEventSeqs: [original.seq],
|
||||
})
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
|
||||
// The replacement is durable but is not another live completion.
|
||||
expect(session.events.filter(event => event.type === 'tool/result')).toHaveLength(2)
|
||||
expect(JSON.stringify(session.deriveMessages())).toContain('tool result middle pruned')
|
||||
expect(liveCompletions()).toHaveLength(1)
|
||||
await live.dispose()
|
||||
live = undefined
|
||||
|
||||
loader = await makeBridgeHarness({ storageDir, withBash: true, script: [] })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
|
||||
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
const replayed = loader.updates.filter(update =>
|
||||
update.sessionUpdate === 'tool_call_update' && update.toolCallId === 'c1')
|
||||
expect(replayed).toHaveLength(1)
|
||||
expect((replayed[0] as { _meta?: { terminal_output?: { data: string } } })._meta?.terminal_output?.data)
|
||||
.toBe('full\n')
|
||||
})
|
||||
|
||||
it('a load whose resume finishes after a client disconnect leaks no live session', async () => {
|
||||
// Stall persistence so transport closes while resume is pending. Whether the SDK rejects first
|
||||
// or the bridge's post-await guard fires, no agent may survive for the dead connection.
|
||||
|
||||
@@ -14,7 +14,7 @@ function messageTextFor(updates: { sessionId?: string; update: CapturedUpdate }[
|
||||
.join('')
|
||||
}
|
||||
|
||||
describe('acp bridge — RFC 011 multi-session isolation', () => {
|
||||
describe('acp bridge — multi-session isolation', () => {
|
||||
let storageDir: string
|
||||
let harness: BridgeHarness | undefined
|
||||
|
||||
|
||||
@@ -105,6 +105,22 @@ describe('streamSessionEventUpdate', () => {
|
||||
expect((failed[0] as { status: string }).status).toBe('failed')
|
||||
})
|
||||
|
||||
it('emits no execution update for a tool-result surface replacement', () => {
|
||||
const replacement = {
|
||||
...evt('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('c1'),
|
||||
content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
|
||||
isError: false,
|
||||
}),
|
||||
seq: 2,
|
||||
surfaceOp: { op: 'replace', start: 1, end: 1 },
|
||||
sourceEventSeqs: [1],
|
||||
} as SessionEvent
|
||||
expect(updatesFor(replacement)).toEqual([])
|
||||
})
|
||||
|
||||
it('drops non-text tool-result content (text-only)', () => {
|
||||
const update = updatesFor(evt('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c1'),
|
||||
@@ -450,6 +466,16 @@ describe('terminal-card mapping (capability-gated)', () => {
|
||||
|
||||
const callEvent = evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'echo hi', description: 'Greet' }) })
|
||||
const resultEvent = evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'hi\n' }], isError: false })
|
||||
const prunedResultEvent = {
|
||||
...resultEvent,
|
||||
seq: 2,
|
||||
data: {
|
||||
...resultEvent.data,
|
||||
content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
|
||||
},
|
||||
surfaceOp: { op: 'replace', start: 1, end: 1 },
|
||||
sourceEventSeqs: [1],
|
||||
} as SessionEvent
|
||||
|
||||
function termUpdates(tool: ToolDefinition, enabled: boolean, cwd: string | undefined, ...events: SessionEvent[]): SessionNotification['update'][] {
|
||||
const presenter = new ToolPresenter(registryOf(tool))
|
||||
@@ -477,6 +503,27 @@ describe('terminal-card mapping (capability-gated)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('live/replay translation preserves the original terminal completion across a pruning rewrite', () => {
|
||||
const updates = termUpdates(
|
||||
termTool({ card: 'terminal' }, { output: 'hi\n', exitCode: 0 }),
|
||||
true,
|
||||
'/work/proj',
|
||||
callEvent,
|
||||
resultEvent,
|
||||
prunedResultEvent,
|
||||
)
|
||||
expect(updates).toHaveLength(2)
|
||||
expect(updates[1]).toEqual({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'c1',
|
||||
status: 'completed',
|
||||
_meta: {
|
||||
terminal_output: { terminal_id: 'c1', data: 'hi\n' },
|
||||
terminal_exit: { terminal_id: 'c1', exit_code: 0 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('capability ON: an ABSOLUTE tool cwd wins; a RELATIVE one resolves against the session cwd', () => {
|
||||
const [absCall] = termUpdates(termTool({ card: 'terminal', cwd: '/explicit/abs' }, { output: 'x' }), true, '/work/proj', callEvent)
|
||||
expect((absCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/explicit/abs')
|
||||
@@ -633,17 +680,38 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
|
||||
// call-time snippet, then the tool/result carries the tool's computed applied-hunk `meta`,
|
||||
// which presentResult narrows into a `diff` result card the bridge forwards as `{ type:
|
||||
// 'diff' }` content blocks. The real tool is required because its result metadata is the contract.
|
||||
it('forwards the applied-hunk meta onto the wire as tool_call_update diff content', async () => {
|
||||
it('live/replay translation keeps the applied diff when a pruning rewrite follows', async () => {
|
||||
const ctx = await fsCtx()
|
||||
const presenter = new ToolPresenter(ctx.tools)
|
||||
const args = JSON.stringify({ file_path: 'src/b.ts', old_string: 'OLD', new_string: 'NEW' })
|
||||
// The applied hunk the tool would compute and persist on the result meta.
|
||||
const meta = { diffs: [{ path: 'src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] }
|
||||
const [, resultUpdate] = updatesWith(
|
||||
const originalResult = evt('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('e1'),
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
isError: false,
|
||||
meta,
|
||||
})
|
||||
const replacement = {
|
||||
...originalResult,
|
||||
seq: 3,
|
||||
data: {
|
||||
...originalResult.data,
|
||||
content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
|
||||
},
|
||||
surfaceOp: { op: 'replace', start: 2, end: 2 },
|
||||
sourceEventSeqs: [2],
|
||||
} as SessionEvent
|
||||
const updates = updatesWith(
|
||||
presenter,
|
||||
evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }),
|
||||
evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'ok' }], isError: false, meta }),
|
||||
originalResult,
|
||||
replacement,
|
||||
)
|
||||
expect(updates).toHaveLength(2)
|
||||
const resultUpdate = updates[1]
|
||||
expect(resultUpdate).toEqual({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'e1',
|
||||
|
||||
@@ -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 |
|
||||
|---|---|
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <code>]`, `[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.
|
||||
|
||||
#### 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.
|
||||
@@ -1,49 +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"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.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-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-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -1,474 +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<Config> = 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') {
|
||||
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. Two subtleties this handles: the loop batches
|
||||
// several queued messages into ONE turn (one idle), so we don't count
|
||||
// sends; and agent.send() 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<typeof setTimeout> | 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<AskUserQuestionAnswer>((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 */
|
||||
@@ -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<string, unknown>
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,33 +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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -1367,10 +1367,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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user