| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119 |
- import { createInterface } from 'node:readline'
- import type { Context } from 'cordis'
- import type {} from '@deepseek-ai/dsh-agent'
- export const name = 'stdio-chat'
- export const inject = ['agents']
- // Copied from examples/echo-agent (welcome text + reasoning rendering
- // adjusted). Deliberately example-local rather than a shared package — two
- // examples don't justify the abstraction yet; revisit at the third.
- /**
- * Minimal UI plugin: reads lines from stdin → agent.send(); renders the
- * agent's stream chunks and tool activity to stdout. Demonstrates that a UI
- * is "just a plugin" — it only consumes the agent/* event taxonomy.
- */
- export function apply(ctx: Context) {
- let inReasoning = false
- ctx.on('agent/stream-chunk', (_agent, _turn, _step, chunk) => {
- if (chunk.type === 'reasoning-delta') {
- // Dim the chain-of-thought so the answer stands out.
- if (!inReasoning) process.stdout.write('\x1B[2m')
- inReasoning = true
- process.stdout.write(chunk.text)
- } else if (chunk.type === 'text-delta') {
- if (inReasoning) process.stdout.write('\x1B[0m\n')
- inReasoning = false
- process.stdout.write(chunk.text)
- }
- })
- ctx.on('agent/turn-start', (agent, turn) => {
- process.stdout.write(`\n[${agent.id} turn ${turn}] `)
- })
- ctx.on('agent/turn-end', () => {
- if (inReasoning) process.stdout.write('\x1B[0m')
- inReasoning = false
- process.stdout.write('\n> ')
- })
- ctx.on('session/event', (_session, event) => {
- if (event.type === 'tool/call') {
- const { name: toolName, arguments: args } = event.data
- if (inReasoning) process.stdout.write('\x1B[0m')
- inReasoning = false
- process.stdout.write(`\n [tool call] ${toolName}(${args})`)
- } else if (event.type === 'tool/result') {
- const { content } = event.data
- const text = content.filter(b => b.type === 'text').map(b => b.text).join('')
- process.stdout.write(`\n [tool result] ${text}\n `)
- }
- })
- ctx.effect(() => {
- const reader = createInterface({ input: process.stdin })
- // 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
- 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 = ctx.agents.get('main')
- if (agent && agent.status !== 'idle') return // a turn is still running
- }
- // Let any final output flush, then exit.
- setTimeout(() => process.exit(0), 200)
- }
- const disposeStatusListener = ctx.on('agent/status', (subject, status) => {
- if (subject.id !== 'main') return
- if (status === 'running') sawRunning = true
- if (status === 'idle') maybeExit()
- })
- reader.on('line', (line) => {
- const text = line.trim()
- if (!text) return
- const agent = ctx.agents.get('main')
- if (!agent) {
- console.error('agent "main" is not running')
- return
- }
- submittedWork = true
- if (agent.status === 'running') {
- agent.steer([{ type: 'text', text }])
- } else {
- agent.send([{ type: 'text', 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
- maybeExit()
- })
- process.stdout.write('coding-agent ready. Give it a coding task (bash is its only tool).\n> ')
- return () => {
- disposed = true
- disposeStatusListener()
- reader.close()
- }
- }, 'stdio-chat')
- }
|