rterm-cli 3.4.4 → 3.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,11 +7,13 @@ no dependencies.
7
7
  ## Install / run
8
8
 
9
9
  ```bash
10
- # from a checkout
10
+ # run directly (nothing to install)
11
+ npx rterm-cli ping
12
+
13
+ # or from a checkout
11
14
  node apps/cli/rterm-cli.mjs ping
12
15
 
13
16
  # link it
14
- npm --workspace @rterm/cli run build 2>/dev/null || true
15
17
  ln -s "$(pwd)/apps/cli/rterm-cli.mjs" /usr/local/bin/rterm
16
18
  ```
17
19
 
@@ -28,11 +30,57 @@ rterm close <tabIdOrName> # close a terminal tab
28
30
  rterm run <tabIdOrName> <command> # run a command in a tab (waits)
29
31
  rterm fleet <tab1,tab2,...> <command> # run on many tabs at once
30
32
  rterm sessions # list chat sessions
33
+ rterm chat # INTERACTIVE persistent chat (see below)
31
34
  rterm chat <sessionId> <message> # send a message to the agent (blocking)
32
35
  rterm dashboard # live dashboard state
33
36
  rterm metrics [--format prometheus] # host metrics
34
37
  ```
35
38
 
39
+ ## Interactive chat — the desktop experience, in your terminal
40
+
41
+ `rterm chat` (with no arguments) opens a persistent, streaming conversation with
42
+ the RTerm agent — the same session, events, and history the desktop app uses:
43
+
44
+ ```text
45
+ $ rterm chat
46
+ Connected to ws://127.0.0.1:17888 — session 96c153ac…
47
+ ── resuming (4 messages) ──
48
+ you> Reply with exactly one word: PONG
49
+ · Reasoning... The user wants me to reply with exactly one word: PONG
50
+ assistant> PONG
51
+ you> /exit
52
+ session 96c153ac… kept server-side — rerun "rterm chat" to resume.
53
+ ```
54
+
55
+ What you get:
56
+
57
+ - **Streaming replies** — text, reasoning, and tool output render live as the
58
+ agent works (`say` / `sub_tool_*` / `command_*` gateway events).
59
+ - **Persistent sessions** — the conversation lives on the backend (SQLite), not
60
+ in the terminal. Exit, kill the process, reboot the machine — the next
61
+ `rterm chat` resumes where you left off (last session id saved in
62
+ `~/.rterm-cli/chat-state.json`; override with `--session <id>`).
63
+ - **History replay** — resuming a session prints the past transcript first.
64
+ - **Command approvals** — when the agent asks to run a command, the CLI pauses
65
+ and prompts `allow? [y/N]`, replying via the same approval RPC the desktop
66
+ uses. Any non-`y` answer denies.
67
+ - **Slash commands**:
68
+
69
+ | Command | Action |
70
+ |---|---|
71
+ | `/new` | Start a fresh session |
72
+ | `/sessions` | List sessions; type a number to resume one |
73
+ | `/rename <title>` | Rename the current session |
74
+ | `/branch` | Branch a new session from the last assistant message |
75
+ | `/export [--simple]` | Export this session as markdown |
76
+ | `/search <query>` | Full-text search across ALL sessions (needs a history bridge) |
77
+ | `/stop` | Stop the running agent task |
78
+ | `/verbose` | Toggle raw gateway-event display |
79
+ | `/exit` (or Ctrl-D) | Leave — the session stays on the server |
80
+
81
+ Flags: `--session <id>` (resume a specific session), `--verbose` (start with
82
+ raw events on).
83
+
36
84
  ## Configuration
37
85
 
38
86
  | Env | Default | Meaning |
package/package.json CHANGED
@@ -1,37 +1,35 @@
1
1
  {
2
2
  "name": "rterm-cli",
3
- "version": "3.4.4",
4
- "description": "rterm \u2014 command CLI for the RTerm / neuralOS backend WebSocket gateway",
3
+ "version": "3.7.4",
4
+ "description": "rterm command CLI for the RTerm / neuralOS backend WebSocket gateway",
5
+ "type": "module",
6
+ "bin": {
7
+ "rterm": "./rterm-cli.mjs",
8
+ "rterm-cli": "./rterm-cli.mjs"
9
+ },
10
+ "files": [
11
+ "rterm-cli.mjs",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
5
17
  "keywords": [
6
- "forward-deployed-engineer",
7
- "fde",
8
- "ai-terminal",
9
- "ai-agent",
10
- "ssh",
11
- "winrm",
12
- "serial-console",
13
- "cisco",
14
- "network-automation",
15
- "sre",
16
- "observability",
17
- "incident-response",
18
- "change-management",
19
- "infrastructure-automation",
20
- "runbooks",
18
+ "rterm",
21
19
  "neuralos",
22
- "aiops",
23
- "ai-sre",
24
- "agentic-ai",
20
+ "terminal",
21
+ "ssh",
22
+ "ai",
23
+ "agent",
24
+ "cli",
25
25
  "chatops",
26
- "self-healing",
27
- "runbook-automation",
28
- "closed-loop-remediation",
29
- "mcp"
26
+ "fde",
27
+ "agentic-ai"
30
28
  ],
31
- "license": "Apache-2.0",
32
- "bin": {
33
- "rterm": "rterm-cli.mjs",
34
- "rterm-cli": "rterm-cli.mjs"
35
- },
36
- "type": "module"
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/DrOlu/RTerm.git",
33
+ "directory": "apps/cli"
34
+ }
37
35
  }
package/rterm-cli.mjs CHANGED
@@ -10,15 +10,21 @@
10
10
  * rterm ping | version | methods | call | terminals | connections
11
11
  * rterm open <name> | close <tab> | run <tab> <cmd> | fleet <tabs> <cmd>
12
12
  * rterm sessions | chat <session> <msg> | dashboard | metrics
13
+ * rterm chat Interactive persistent chat (REPL):
14
+ * streaming replies, session resume,
15
+ * command approvals, slash commands.
13
16
  */
14
17
 
15
- import { readFileSync, existsSync } from 'node:fs'
18
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
16
19
  import { homedir } from 'node:os'
17
20
  import { join } from 'node:path'
21
+ import readline from 'node:readline'
18
22
 
19
23
  const DEFAULT_HOST = process.env.RTERM_HOST || '127.0.0.1'
20
24
  const DEFAULT_PORT = Number(process.env.RTERM_PORT || 17888)
21
25
  const DEFAULT_URL = process.env.RTERM_URL || `ws://${DEFAULT_HOST}:${DEFAULT_PORT}`
26
+ const STATE_DIR = join(homedir(), '.rterm-cli')
27
+ const STATE_FILE = join(STATE_DIR, 'chat-state.json')
22
28
 
23
29
  // ── tiny arg parser ─────────────────────────────────────────────────────────
24
30
 
@@ -85,11 +91,31 @@ function loadToken() {
85
91
  return null
86
92
  }
87
93
 
94
+ /**
95
+ * Append the token as an `access_token` query parameter. The gateway accepts
96
+ * the token from the Authorization header OR this query param; the query param
97
+ * works on every WebSocket client (native WS ignores constructor options on
98
+ * some runtimes, and browsers cannot send custom headers at all).
99
+ */
100
+ function urlWithToken(url, token) {
101
+ if (!token) return url
102
+ try {
103
+ const u = new URL(url)
104
+ if (!u.searchParams.has('access_token')) u.searchParams.set('access_token', token)
105
+ return u.toString()
106
+ } catch {
107
+ return url
108
+ }
109
+ }
110
+
88
111
  async function openSocket(url, token) {
89
112
  const headers = token ? { Authorization: `Bearer ${token}` } : undefined
90
113
  if (typeof globalThis.WebSocket === 'function') {
91
114
  return await new Promise((resolve, reject) => {
92
- const ws = new globalThis.WebSocket(url)
115
+ // Pass the token BOTH ways: as a query param (always works) and try the
116
+ // options object (native WebSocket in Node >= 22 forwards extra options
117
+ // to undici and sends the header; browsers ignore it harmlessly).
118
+ const ws = new globalThis.WebSocket(urlWithToken(url, token), headers ? { headers } : undefined)
93
119
  ws.onopen = () => resolve(ws)
94
120
  ws.onerror = () => reject(new Error(`Cannot connect to ${url}. Is the backend running? (gybackend)`))
95
121
  })
@@ -99,7 +125,7 @@ async function openSocket(url, token) {
99
125
  const require = createRequire(import.meta.url)
100
126
  const WS = require('ws')
101
127
  return await new Promise((resolve, reject) => {
102
- const ws = new WS(url, { headers })
128
+ const ws = new WS(urlWithToken(url, token), { headers })
103
129
  ws.on('open', () => resolve(ws))
104
130
  ws.on('error', () => reject(new Error(`Cannot connect to ${url}. Is the backend running? (gybackend)`)))
105
131
  })
@@ -157,6 +183,95 @@ function makeClient(url, token) {
157
183
  }
158
184
  }
159
185
 
186
+ // ── persistent (multiplexed) client for interactive chat ────────────────────
187
+
188
+ /**
189
+ * One long-lived WebSocket; JSON-RPC calls are multiplexed by id and every
190
+ * gateway event frame is fanned out to registered listeners. This is what
191
+ * makes the interactive chat possible: we LISTEN while we TALK.
192
+ */
193
+ class PersistentClient {
194
+ constructor(url, token) {
195
+ this.url = url
196
+ this.token = token
197
+ this.ws = null
198
+ this.pending = new Map() // id -> { resolve, reject }
199
+ this.eventListeners = new Set() // (frame) => void
200
+ }
201
+
202
+ async connect() {
203
+ this.ws = await openSocket(this.url, this.token)
204
+ const wire = (raw) => {
205
+ let frame
206
+ try {
207
+ frame = JSON.parse(typeof raw === 'string' ? raw : raw.toString())
208
+ } catch { return }
209
+ if (frame.type === 'gateway:response' && frame.id !== undefined) {
210
+ const p = this.pending.get(String(frame.id))
211
+ if (p) {
212
+ this.pending.delete(String(frame.id))
213
+ if (frame.ok) p.resolve(frame.result)
214
+ else p.reject(frame.error || new Error('gateway error'))
215
+ }
216
+ return
217
+ }
218
+ if (frame.type === 'gateway:event' || frame.type === 'gateway:ui-update') {
219
+ for (const fn of this.eventListeners) {
220
+ try { fn(frame) } catch { /* listener errors never kill the socket */ }
221
+ }
222
+ }
223
+ }
224
+ if (typeof this.ws.addEventListener === 'function') {
225
+ this.ws.addEventListener('message', (e) => wire(e.data))
226
+ this.ws.addEventListener('close', () => this.onClosed())
227
+ } else if (typeof this.ws.on === 'function') {
228
+ this.ws.on('message', (d) => wire(d))
229
+ this.ws.on('close', () => this.onClosed())
230
+ } else {
231
+ this.ws.onmessage = (e) => wire(e.data)
232
+ this.ws.onclose = () => this.onClosed()
233
+ }
234
+ }
235
+
236
+ onClosed() {
237
+ // Reject everything in flight; the REPL surfaces a clear message.
238
+ for (const [, p] of this.pending) {
239
+ p.reject(new Error('Connection closed. Is the backend still running?'))
240
+ }
241
+ this.pending.clear()
242
+ }
243
+
244
+ get connected() {
245
+ return this.ws && this.ws.readyState === 1
246
+ }
247
+
248
+ async reconnect() {
249
+ try { this.ws?.close?.() } catch { /* ignore */ }
250
+ await this.connect()
251
+ }
252
+
253
+ onEvent(fn) {
254
+ this.eventListeners.add(fn)
255
+ return () => this.eventListeners.delete(fn)
256
+ }
257
+
258
+ call(method, params, timeoutMs = 60_000) {
259
+ if (!this.connected) throw new Error('Not connected.')
260
+ const id = String(nextId++)
261
+ return new Promise((resolve, reject) => {
262
+ const timer = setTimeout(() => {
263
+ this.pending.delete(id)
264
+ reject(new Error(`Timeout calling ${method}`))
265
+ }, timeoutMs)
266
+ this.pending.set(id, {
267
+ resolve: (v) => { clearTimeout(timer); resolve(v) },
268
+ reject: (e) => { clearTimeout(timer); reject(e) },
269
+ })
270
+ this.ws.send(JSON.stringify({ id, method, ...(params !== undefined ? { params } : {}) }))
271
+ })
272
+ }
273
+ }
274
+
160
275
  // ── output helpers ──────────────────────────────────────────────────────────
161
276
 
162
277
  function printJson(value) {
@@ -168,6 +283,18 @@ function fail(message) {
168
283
  process.exit(1)
169
284
  }
170
285
 
286
+ const C = process.stdout.isTTY ? {
287
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
288
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
289
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
290
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
291
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
292
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
293
+ } : {
294
+ dim: (s) => s, bold: (s) => s, cyan: (s) => s,
295
+ yellow: (s) => s, red: (s) => s, green: (s) => s,
296
+ }
297
+
171
298
  const HELP = `rterm — command CLI for the RTerm / neuralOS backend gateway
172
299
 
173
300
  Usage:
@@ -182,10 +309,23 @@ Usage:
182
309
  rterm run <tabIdOrName> <command> Run a command in a terminal tab (waits)
183
310
  rterm fleet <tab1,tab2,...> <command> Run a command on many tabs at once
184
311
  rterm sessions List chat sessions
312
+ rterm chat Interactive persistent chat (REPL)
185
313
  rterm chat <sessionId> <message> Send a message to the agent (blocking)
186
314
  rterm dashboard Print the live dashboard state
187
315
  rterm metrics [--format prometheus] Host metrics
188
316
 
317
+ Interactive chat slash commands:
318
+ /new Start a fresh session
319
+ /sessions List sessions (pick one to resume)
320
+ /rename <title> Rename the current session
321
+ /branch Branch from the last assistant message
322
+ /export [--simple] Export this session as markdown
323
+ /search <query> Full-text search across ALL sessions
324
+ /stop Stop the running agent task
325
+ /verbose Toggle raw event display
326
+ /help This list
327
+ /exit Leave the chat (session is kept server-side)
328
+
189
329
  Options:
190
330
  --url ws://host:port Gateway URL (default ${DEFAULT_URL}, env RTERM_URL)
191
331
  --token <token> Access token (env RTERM_TOKEN; non-localhost requires one)
@@ -302,6 +442,486 @@ async function runInTab(client, tabIdOrName, commandText) {
302
442
  return stripAnsi(output).trimEnd()
303
443
  }
304
444
 
445
+ // ── chat state (session resume) ─────────────────────────────────────────────
446
+
447
+ function loadChatState() {
448
+ try {
449
+ if (existsSync(STATE_FILE)) {
450
+ const parsed = JSON.parse(readFileSync(STATE_FILE, 'utf8'))
451
+ if (parsed && typeof parsed === 'object') return parsed
452
+ }
453
+ } catch { /* corrupted state → start fresh */ }
454
+ return {}
455
+ }
456
+
457
+ function saveChatState(patch) {
458
+ try {
459
+ mkdirSync(STATE_DIR, { recursive: true })
460
+ const next = { ...loadChatState(), ...patch }
461
+ writeFileSync(STATE_FILE, JSON.stringify(next, null, 2))
462
+ } catch { /* best-effort */ }
463
+ }
464
+
465
+ // ── interactive chat ────────────────────────────────────────────────────────
466
+
467
+ /** Extract {sessionId, event} from a gateway:event frame (null otherwise). */
468
+ function extractAgentEvent(frame) {
469
+ if (frame?.type !== 'gateway:event') return null
470
+ const p = frame.payload
471
+ if (p?.type !== 'agent:event') return null
472
+ if (!p.payload || typeof p.payload !== 'object') return null
473
+ return { sessionId: p.sessionId, event: p.payload }
474
+ }
475
+
476
+ function shortId(id) {
477
+ return typeof id === 'string' && id.length > 10 ? `${id.slice(0, 8)}…` : String(id)
478
+ }
479
+
480
+ class InteractiveChat {
481
+ constructor(client, flags) {
482
+ this.client = client
483
+ this.flags = flags || {}
484
+ this.sessionId = null
485
+ this.verbose = this.flags.verbose === true
486
+ this.turnActive = false
487
+ this.turnResolve = null
488
+ this.currentSayId = null
489
+ this.sayOpen = false
490
+ this.lastAssistantMessageId = null
491
+ this.unsubscribe = null
492
+ this.rl = null
493
+ }
494
+
495
+ async start() {
496
+ const state = loadChatState()
497
+ const requested = typeof this.flags.session === 'string' && this.flags.session
498
+ ? this.flags.session
499
+ : (state.lastSessionId || null)
500
+
501
+ if (requested) {
502
+ const ok = await this.tryResume(requested)
503
+ if (!ok) console.log(C.dim(`(saved session ${shortId(requested)} no longer exists — starting fresh)`))
504
+ }
505
+ if (!this.sessionId) {
506
+ await this.newSession()
507
+ }
508
+
509
+ this.unsubscribe = this.client.onEvent((frame) => this.handleFrame(frame))
510
+
511
+ console.log(C.dim(`Connected to ${this.client.url} — session ${shortId(this.sessionId)}`))
512
+ console.log(C.dim('Type a message, or /help for commands. /exit to leave.\n'))
513
+ await this.printHistory()
514
+ await this.repl()
515
+ }
516
+
517
+ async tryResume(sessionId) {
518
+ try {
519
+ const result = await this.client.call('session:get', { sessionId })
520
+ if (result?.session?.id || result?.session?.sessionId) {
521
+ this.sessionId = sessionId
522
+ return true
523
+ }
524
+ return false
525
+ } catch {
526
+ return false
527
+ }
528
+ }
529
+
530
+ async newSession() {
531
+ const result = await this.client.call('gateway:createSession')
532
+ this.sessionId = result?.sessionId
533
+ if (!this.sessionId) throw new Error('gateway:createSession returned no sessionId')
534
+ saveChatState({ lastSessionId: this.sessionId, lastUrl: this.client.url })
535
+ }
536
+
537
+ async switchSession(sessionId) {
538
+ this.sessionId = sessionId
539
+ saveChatState({ lastSessionId: sessionId, lastUrl: this.client.url })
540
+ console.log(C.dim(`\n── switched to session ${shortId(sessionId)} ──`))
541
+ await this.printHistory()
542
+ }
543
+
544
+ async printHistory() {
545
+ let messages = []
546
+ try {
547
+ messages = await this.client.call('agent:getUiMessages', { id: this.sessionId })
548
+ if (!Array.isArray(messages)) messages = []
549
+ } catch {
550
+ return // history bridge unavailable — fine on a fresh session
551
+ }
552
+ if (messages.length === 0) {
553
+ console.log(C.dim('(new session — no history yet)'))
554
+ return
555
+ }
556
+ console.log(C.dim(`── resuming (${messages.length} messages) ──`))
557
+ for (const m of messages) {
558
+ const role = m.role === 'user' ? 'you' : m.role === 'assistant' ? 'assistant' : m.role
559
+ const text = typeof m.content === 'string' ? m.content : ''
560
+ if (!text.trim()) continue
561
+ if (m.streaming) continue // never persisted as streaming in practice
562
+ console.log(`${C.bold(C.cyan(role))}> ${text.length > 2000 ? `${text.slice(0, 2000)}…` : text}`)
563
+ }
564
+ const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant' && m.id)
565
+ this.lastAssistantMessageId = lastAssistant?.id || null
566
+ console.log(C.dim('── end of history ──\n'))
567
+ }
568
+
569
+ handleFrame(frame) {
570
+ const extracted = extractAgentEvent(frame)
571
+ if (!extracted || extracted.sessionId !== this.sessionId) return
572
+ const ev = extracted.event
573
+ if (this.verbose) {
574
+ console.log(C.dim(` [event] ${JSON.stringify(ev).slice(0, 300)}`))
575
+ }
576
+ switch (ev.type) {
577
+ case 'say': {
578
+ this.renderSay(ev)
579
+ break
580
+ }
581
+ case 'user_input':
582
+ break // we echo input locally
583
+ case 'command_started': {
584
+ this.closeSay()
585
+ console.log(C.dim(`⚙ ${ev.command || ev.toolName || 'running…'}`))
586
+ break
587
+ }
588
+ case 'command_finished': {
589
+ this.closeSay()
590
+ const ok = ev.exitCode === undefined || ev.exitCode === 0
591
+ console.log(C.dim(`⚙ done${ev.exitCode !== undefined ? ` (exit ${ev.exitCode})` : ''}${ok ? '' : ' ✗'}`))
592
+ break
593
+ }
594
+ case 'sub_tool_started': {
595
+ this.closeSay()
596
+ process.stdout.write(C.dim(`· ${ev.title || ev.toolName || 'thinking'} `))
597
+ break
598
+ }
599
+ case 'sub_tool_delta': {
600
+ if (typeof ev.outputDelta === 'string') process.stdout.write(C.dim(ev.outputDelta))
601
+ break
602
+ }
603
+ case 'sub_tool_finished': {
604
+ process.stdout.write('\n')
605
+ break
606
+ }
607
+ case 'alert': {
608
+ this.closeSay()
609
+ console.log(C.yellow(`⚠ ${ev.message || ''}`))
610
+ break
611
+ }
612
+ case 'error': {
613
+ this.closeSay()
614
+ console.log(C.red(`✗ ${ev.message || ev.error || 'agent error'}`))
615
+ break
616
+ }
617
+ case 'command_ask': {
618
+ this.closeSay()
619
+ void this.handleApproval(ev)
620
+ break
621
+ }
622
+ case 'done': {
623
+ this.closeSay()
624
+ this.lastAssistantMessageId = ev.messageId || this.lastAssistantMessageId
625
+ this.finishTurn()
626
+ break
627
+ }
628
+ default:
629
+ break
630
+ }
631
+ }
632
+
633
+ renderSay(ev) {
634
+ const delta = typeof ev.content === 'string' ? ev.content : (typeof ev.outputDelta === 'string' ? ev.outputDelta : '')
635
+ if (!delta) return
636
+ const id = ev.messageId || null
637
+ if (id && id !== this.currentSayId) {
638
+ if (this.sayOpen) process.stdout.write('\n\n')
639
+ else if (this.currentSayId !== null) process.stdout.write('\n\n')
640
+ process.stdout.write(`${C.bold(C.cyan('assistant'))}> `)
641
+ this.currentSayId = id
642
+ this.sayOpen = true
643
+ }
644
+ process.stdout.write(delta)
645
+ }
646
+
647
+ closeSay() {
648
+ if (this.sayOpen) {
649
+ process.stdout.write('\n')
650
+ this.sayOpen = false
651
+ }
652
+ }
653
+
654
+ async handleApproval(ev) {
655
+ const command = ev.command || ''
656
+ const toolName = ev.toolName || 'Command'
657
+ this.closeSay()
658
+ console.log(C.yellow(`\n⏸ approval needed — ${toolName}:`))
659
+ console.log(C.yellow(` ${command}`))
660
+ // The turn is active (readline paused for the streaming turn) — resume
661
+ // input so the user can actually answer; otherwise this deadlocks.
662
+ this.rl.resume()
663
+ process.stdout.write(C.bold('allow? [y/N] '))
664
+ this.pendingApproval = {
665
+ approvalId: ev.approvalId,
666
+ resolve: async (answer) => {
667
+ const trimmed = (answer || '').trim().toLowerCase()
668
+ const decision = trimmed === 'y' || trimmed === 'yes' ? 'allow' : 'deny'
669
+ try {
670
+ await this.client.call('agent:replyCommandApproval', { approvalId: ev.approvalId, decision })
671
+ console.log(C.dim(decision === 'allow' ? '(allowed)' : '(denied)'))
672
+ } catch (error) {
673
+ console.log(C.red(`approval reply failed: ${errorMessage(error)}`))
674
+ }
675
+ this.pendingApproval = null
676
+ },
677
+ }
678
+ }
679
+
680
+ finishTurn() {
681
+ if (this.turnResolve) {
682
+ const r = this.turnResolve
683
+ this.turnResolve = null
684
+ r()
685
+ }
686
+ // EOF arrived while the turn was streaming → shut down now that it's done.
687
+ if (this.stdinClosed && !this.quitting) this.shutdown()
688
+ }
689
+
690
+ async runTurn(userInput) {
691
+ this.turnActive = true
692
+ this.currentSayId = null
693
+ this.sayOpen = false
694
+ const turnPromise = new Promise((resolve) => { this.turnResolve = resolve })
695
+ try {
696
+ await this.client.call('agent:startTaskAsync', { sessionId: this.sessionId, userInput })
697
+ } catch (error) {
698
+ this.turnActive = false
699
+ throw error
700
+ }
701
+ await turnPromise
702
+ this.turnActive = false
703
+ }
704
+
705
+ async stopTask() {
706
+ try {
707
+ await this.client.call('agent:stopTask', { sessionId: this.sessionId })
708
+ console.log(C.dim('(stop requested)'))
709
+ } catch (error) {
710
+ console.log(C.red(`stop failed: ${errorMessage(error)}`))
711
+ }
712
+ }
713
+
714
+ async listSessionsPick() {
715
+ const result = await this.client.call('session:list')
716
+ const sessions = Array.isArray(result?.sessions) ? result.sessions : []
717
+ if (sessions.length === 0) {
718
+ console.log(C.dim('(no sessions)'))
719
+ return
720
+ }
721
+ sessions.forEach((s, i) => {
722
+ const title = s.title || s.name || '(untitled)'
723
+ const when = s.updatedAt || s.lastActivity || ''
724
+ console.log(` ${String(i + 1).padStart(3)}. ${shortId(s.id)} ${title}${when ? C.dim(` ${when}`) : ''}`)
725
+ })
726
+ this.pendingPick = {
727
+ resolve: async (answer) => {
728
+ const idx = Number.parseInt((answer || '').trim(), 10)
729
+ if (Number.isInteger(idx) && idx >= 1 && idx <= sessions.length) {
730
+ await this.switchSession(sessions[idx - 1].id)
731
+ }
732
+ this.pendingPick = null
733
+ },
734
+ }
735
+ }
736
+
737
+ async branchFromLast() {
738
+ if (!this.lastAssistantMessageId) {
739
+ console.log(C.dim('(no assistant message to branch from yet)'))
740
+ return
741
+ }
742
+ try {
743
+ const result = await this.client.call('agent:branchFromMessage', {
744
+ sessionId: this.sessionId,
745
+ messageId: this.lastAssistantMessageId,
746
+ })
747
+ const newId = result?.sessionId || result?.id
748
+ if (newId) await this.switchSession(newId)
749
+ else console.log(C.dim('(branch created — see /sessions)'))
750
+ } catch (error) {
751
+ console.log(C.red(`branch failed: ${errorMessage(error)}`))
752
+ }
753
+ }
754
+
755
+ async exportSession(mode) {
756
+ try {
757
+ const result = await this.client.call('agent:exportHistory', { sessionId: this.sessionId, mode })
758
+ if (typeof result === 'string') console.log(result)
759
+ else if (typeof result?.content === 'string') console.log(result.content)
760
+ else if (typeof result?.markdown === 'string') console.log(result.markdown)
761
+ else printJson(result)
762
+ } catch (error) {
763
+ console.log(C.red(`export failed: ${errorMessage(error)}`))
764
+ }
765
+ }
766
+
767
+ async searchHistory(query) {
768
+ try {
769
+ const result = await this.client.call('history:search', { query })
770
+ printJson(result)
771
+ } catch (error) {
772
+ console.log(C.red(`search failed: ${errorMessage(error)}`))
773
+ }
774
+ }
775
+
776
+ async handleSlash(line) {
777
+ const [cmd, ...rest] = line.slice(1).split(/\s+/)
778
+ const arg = rest.join(' ')
779
+ switch ((cmd || '').toLowerCase()) {
780
+ case 'new': {
781
+ await this.newSession()
782
+ console.log(C.dim(`── new session ${shortId(this.sessionId)} ──`))
783
+ return true
784
+ }
785
+ case 'sessions':
786
+ await this.listSessionsPick()
787
+ return true
788
+ case 'rename': {
789
+ if (!arg) { console.log(C.dim('usage: /rename <title>')); return true }
790
+ try {
791
+ await this.client.call('agent:renameSession', { sessionId: this.sessionId, newTitle: arg })
792
+ console.log(C.dim(`renamed to "${arg}"`))
793
+ } catch (error) { console.log(C.red(errorMessage(error))) }
794
+ return true
795
+ }
796
+ case 'branch':
797
+ await this.branchFromLast()
798
+ return true
799
+ case 'export':
800
+ await this.exportSession(this.flags.simple || arg.includes('--simple') ? 'simple' : 'detailed')
801
+ return true
802
+ case 'search':
803
+ if (!arg) { console.log(C.dim('usage: /search <query>')); return true }
804
+ await this.searchHistory(arg)
805
+ return true
806
+ case 'stop':
807
+ await this.stopTask()
808
+ return true
809
+ case 'verbose':
810
+ this.verbose = !this.verbose
811
+ console.log(C.dim(`verbose ${this.verbose ? 'on' : 'off'}`))
812
+ return true
813
+ case 'help':
814
+ console.log(HELP.split('Interactive chat slash commands:')[1]?.split('Options:')[0]?.trim() || 'see /exit')
815
+ return true
816
+ case 'exit':
817
+ case 'quit':
818
+ case 'q':
819
+ return false
820
+ default:
821
+ console.log(C.dim(`unknown command "/${cmd}" — /help for the list`))
822
+ return true
823
+ }
824
+ }
825
+
826
+ async repl() {
827
+ // Event-driven readline (NOT readline/promises question()): one 'line'
828
+ // handler dispatches by input state (approval → pick → command/chat).
829
+ // This works identically for a TTY and piped stdin, and never deadlocks.
830
+ this.rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: `${C.bold(C.green('you'))}> ` })
831
+ this.pendingApproval = null
832
+ this.pendingPick = null
833
+ this.quitting = false
834
+
835
+ this.rl.on('line', (line) => {
836
+ void this.onLine(line)
837
+ })
838
+ this.rl.on('close', () => {
839
+ // stdin EOF (piped input exhausted, Ctrl-D, or Ctrl-C on some platforms).
840
+ // If a turn is streaming, let it finish first (finishTurn shuts down);
841
+ // otherwise drain gracefully — NEVER process.exit() here, it would race
842
+ // in-flight async line handlers and truncate pending stdout writes.
843
+ this.stdinClosed = true
844
+ if (!this.turnActive) this.shutdown()
845
+ })
846
+ this.rl.prompt()
847
+ // Resolve only when the REPL shuts down — keeps main() alive.
848
+ return new Promise((resolve) => { this.replDone = resolve })
849
+ }
850
+
851
+ shutdown() {
852
+ if (this.quitting) return
853
+ this.quitting = true
854
+ this.unsubscribe?.()
855
+ console.log(C.dim(`\nsession ${shortId(this.sessionId)} kept server-side — rerun "rterm chat" to resume.`))
856
+ try { this.rl?.close() } catch { /* ignore */ }
857
+ try { this.client.ws?.close?.() } catch { /* ignore */ }
858
+ this.replDone?.()
859
+ }
860
+
861
+ async onLine(line) {
862
+ if (this.quitting) return
863
+ const trimmed = line.trim()
864
+
865
+ // 1. Pending approval prompt captures the next line.
866
+ if (this.pendingApproval) {
867
+ const resolver = this.pendingApproval.resolve
868
+ await resolver(trimmed)
869
+ this.rl.prompt()
870
+ return
871
+ }
872
+ // 2. Pending session-pick prompt captures the next line.
873
+ if (this.pendingPick) {
874
+ const resolver = this.pendingPick.resolve
875
+ await resolver(trimmed)
876
+ this.rl.prompt()
877
+ return
878
+ }
879
+ // 3. Slash commands.
880
+ if (trimmed.startsWith('/')) {
881
+ const keepGoing = await this.handleSlash(trimmed)
882
+ if (!keepGoing) {
883
+ this.shutdown()
884
+ return
885
+ }
886
+ this.rl.prompt()
887
+ return
888
+ }
889
+ // 4. Empty line → just re-prompt.
890
+ if (!trimmed) {
891
+ this.rl.prompt()
892
+ return
893
+ }
894
+ // 5. A chat turn. The prompt is suppressed while the agent streams;
895
+ // the 'done' event re-prompts via finishTurn().
896
+ if (this.turnActive) {
897
+ console.log(C.dim('(agent is still running — /stop to interrupt)'))
898
+ this.rl.prompt()
899
+ return
900
+ }
901
+ this.rl.pause()
902
+ try {
903
+ await this.runTurn(trimmed)
904
+ } catch (error) {
905
+ console.log(C.red(`Error: ${errorMessage(error)}`))
906
+ if (!this.client.connected) {
907
+ try {
908
+ await this.client.reconnect()
909
+ this.unsubscribe?.()
910
+ this.unsubscribe = this.client.onEvent((frame) => this.handleFrame(frame))
911
+ console.log(C.dim('reconnected.'))
912
+ } catch {
913
+ console.log(C.red('reconnect failed — exiting.'))
914
+ this.quitting = true
915
+ this.rl.close()
916
+ process.exit(1)
917
+ }
918
+ }
919
+ }
920
+ this.rl.resume()
921
+ this.rl.prompt()
922
+ }
923
+ }
924
+
305
925
  // ── commands ────────────────────────────────────────────────────────────────
306
926
 
307
927
  async function main() {
@@ -420,7 +1040,16 @@ async function main() {
420
1040
  case 'chat': {
421
1041
  const sessionId = positional[1]
422
1042
  const message = positional.slice(2).join(' ')
423
- if (!sessionId || !message) fail('chat needs: rterm chat <sessionId> <message>')
1043
+ if (!sessionId) {
1044
+ // Interactive persistent chat (the desktop-style experience).
1045
+ // chat.start() resolves only when the REPL shuts down.
1046
+ const pclient = new PersistentClient(url, token)
1047
+ await pclient.connect()
1048
+ const chat = new InteractiveChat(pclient, flags)
1049
+ await chat.start()
1050
+ process.exit(0)
1051
+ }
1052
+ if (!message) fail('chat needs: rterm chat <sessionId> <message> (or "rterm chat" for interactive mode)')
424
1053
  printJson(await client.call('agent:startTask', { sessionId, userInput: message }))
425
1054
  break
426
1055
  }
Binary file