stdio-chat.ts 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. import { createInterface } from 'node:readline'
  2. import type { Context } from 'cordis'
  3. import type {} from '@deepseek-ai/dsh-agent'
  4. export const name = 'stdio-chat'
  5. export const inject = ['agents']
  6. // Copied from examples/echo-agent (welcome text + reasoning rendering
  7. // adjusted). Deliberately example-local rather than a shared package — two
  8. // examples don't justify the abstraction yet; revisit at the third.
  9. /**
  10. * Minimal UI plugin: reads lines from stdin → agent.send(); renders the
  11. * agent's stream chunks and tool activity to stdout. Demonstrates that a UI
  12. * is "just a plugin" — it only consumes the agent/* event taxonomy.
  13. */
  14. export function apply(ctx: Context) {
  15. let inReasoning = false
  16. ctx.on('agent/stream-chunk', (_agent, _turn, _step, chunk) => {
  17. if (chunk.type === 'reasoning-delta') {
  18. // Dim the chain-of-thought so the answer stands out.
  19. if (!inReasoning) process.stdout.write('\x1B[2m')
  20. inReasoning = true
  21. process.stdout.write(chunk.text)
  22. } else if (chunk.type === 'text-delta') {
  23. if (inReasoning) process.stdout.write('\x1B[0m\n')
  24. inReasoning = false
  25. process.stdout.write(chunk.text)
  26. }
  27. })
  28. ctx.on('agent/turn-start', (agent, turn) => {
  29. process.stdout.write(`\n[${agent.id} turn ${turn}] `)
  30. })
  31. ctx.on('agent/turn-end', () => {
  32. if (inReasoning) process.stdout.write('\x1B[0m')
  33. inReasoning = false
  34. process.stdout.write('\n> ')
  35. })
  36. ctx.on('session/event', (_session, event) => {
  37. if (event.type === 'tool/call') {
  38. const { name: toolName, arguments: args } = event.data
  39. if (inReasoning) process.stdout.write('\x1B[0m')
  40. inReasoning = false
  41. process.stdout.write(`\n [tool call] ${toolName}(${args})`)
  42. } else if (event.type === 'tool/result') {
  43. const { content } = event.data
  44. const text = content.filter(b => b.type === 'text').map(b => b.text).join('')
  45. process.stdout.write(`\n [tool result] ${text}\n `)
  46. }
  47. })
  48. ctx.effect(() => {
  49. const reader = createInterface({ input: process.stdin })
  50. // Piped-input exit, once stdin reaches EOF:
  51. // - If no line ever submitted work (empty stdin, blank-only lines), exit
  52. // immediately — no turn will ever start, so there is nothing to wait
  53. // for. (Gating on an observed 'running' here would hang forever.)
  54. // - If work WAS submitted, exit the next time the agent settles to idle
  55. // AFTER having run. Two subtleties this handles: the loop batches
  56. // several queued messages into ONE turn (one idle), so we don't count
  57. // sends; and agent.send() does NOT synchronously flip status to
  58. // 'running', so requiring an observed 'running' first (`sawRunning`)
  59. // avoids exiting in the gap before the turn starts and dropping work.
  60. let stdinClosed = false
  61. let disposed = false
  62. let submittedWork = false
  63. let sawRunning = false
  64. const maybeExit = (): void => {
  65. if (disposed || !stdinClosed) return
  66. // No work submitted: nothing will ever run, exit straight away.
  67. // Work submitted: wait until a turn has run and the agent is idle.
  68. if (submittedWork) {
  69. if (!sawRunning) return
  70. const agent = ctx.agents.get('main')
  71. if (agent && agent.status !== 'idle') return // a turn is still running
  72. }
  73. // Let any final output flush, then exit.
  74. setTimeout(() => process.exit(0), 200)
  75. }
  76. const disposeStatusListener = ctx.on('agent/status', (subject, status) => {
  77. if (subject.id !== 'main') return
  78. if (status === 'running') sawRunning = true
  79. if (status === 'idle') maybeExit()
  80. })
  81. reader.on('line', (line) => {
  82. const text = line.trim()
  83. if (!text) return
  84. const agent = ctx.agents.get('main')
  85. if (!agent) {
  86. console.error('agent "main" is not running')
  87. return
  88. }
  89. submittedWork = true
  90. if (agent.status === 'running') {
  91. agent.steer([{ type: 'text', text }])
  92. } else {
  93. agent.send([{ type: 'text', text }])
  94. }
  95. })
  96. reader.on('close', () => {
  97. // Fires for BOTH stdin EOF and plugin disposal (reader.close() below);
  98. // `disposed` guards teardown so HMR/dispose never exits the process.
  99. stdinClosed = true
  100. maybeExit()
  101. })
  102. process.stdout.write('coding-agent ready. Give it a coding task (bash is its only tool).\n> ')
  103. return () => {
  104. disposed = true
  105. disposeStatusListener()
  106. reader.close()
  107. }
  108. }, 'stdio-chat')
  109. }