rterm-cli 3.4.1 → 3.7.3

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.1",
4
- "description": "rterm \u2014 command CLI for the RTerm / neuralOS backend WebSocket gateway",
3
+ "version": "3.7.3",
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
 
@@ -157,6 +163,95 @@ function makeClient(url, token) {
157
163
  }
158
164
  }
159
165
 
166
+ // ── persistent (multiplexed) client for interactive chat ────────────────────
167
+
168
+ /**
169
+ * One long-lived WebSocket; JSON-RPC calls are multiplexed by id and every
170
+ * gateway event frame is fanned out to registered listeners. This is what
171
+ * makes the interactive chat possible: we LISTEN while we TALK.
172
+ */
173
+ class PersistentClient {
174
+ constructor(url, token) {
175
+ this.url = url
176
+ this.token = token
177
+ this.ws = null
178
+ this.pending = new Map() // id -> { resolve, reject }
179
+ this.eventListeners = new Set() // (frame) => void
180
+ }
181
+
182
+ async connect() {
183
+ this.ws = await openSocket(this.url, this.token)
184
+ const wire = (raw) => {
185
+ let frame
186
+ try {
187
+ frame = JSON.parse(typeof raw === 'string' ? raw : raw.toString())
188
+ } catch { return }
189
+ if (frame.type === 'gateway:response' && frame.id !== undefined) {
190
+ const p = this.pending.get(String(frame.id))
191
+ if (p) {
192
+ this.pending.delete(String(frame.id))
193
+ if (frame.ok) p.resolve(frame.result)
194
+ else p.reject(frame.error || new Error('gateway error'))
195
+ }
196
+ return
197
+ }
198
+ if (frame.type === 'gateway:event' || frame.type === 'gateway:ui-update') {
199
+ for (const fn of this.eventListeners) {
200
+ try { fn(frame) } catch { /* listener errors never kill the socket */ }
201
+ }
202
+ }
203
+ }
204
+ if (typeof this.ws.addEventListener === 'function') {
205
+ this.ws.addEventListener('message', (e) => wire(e.data))
206
+ this.ws.addEventListener('close', () => this.onClosed())
207
+ } else if (typeof this.ws.on === 'function') {
208
+ this.ws.on('message', (d) => wire(d))
209
+ this.ws.on('close', () => this.onClosed())
210
+ } else {
211
+ this.ws.onmessage = (e) => wire(e.data)
212
+ this.ws.onclose = () => this.onClosed()
213
+ }
214
+ }
215
+
216
+ onClosed() {
217
+ // Reject everything in flight; the REPL surfaces a clear message.
218
+ for (const [, p] of this.pending) {
219
+ p.reject(new Error('Connection closed. Is the backend still running?'))
220
+ }
221
+ this.pending.clear()
222
+ }
223
+
224
+ get connected() {
225
+ return this.ws && this.ws.readyState === 1
226
+ }
227
+
228
+ async reconnect() {
229
+ try { this.ws?.close?.() } catch { /* ignore */ }
230
+ await this.connect()
231
+ }
232
+
233
+ onEvent(fn) {
234
+ this.eventListeners.add(fn)
235
+ return () => this.eventListeners.delete(fn)
236
+ }
237
+
238
+ call(method, params, timeoutMs = 60_000) {
239
+ if (!this.connected) throw new Error('Not connected.')
240
+ const id = String(nextId++)
241
+ return new Promise((resolve, reject) => {
242
+ const timer = setTimeout(() => {
243
+ this.pending.delete(id)
244
+ reject(new Error(`Timeout calling ${method}`))
245
+ }, timeoutMs)
246
+ this.pending.set(id, {
247
+ resolve: (v) => { clearTimeout(timer); resolve(v) },
248
+ reject: (e) => { clearTimeout(timer); reject(e) },
249
+ })
250
+ this.ws.send(JSON.stringify({ id, method, ...(params !== undefined ? { params } : {}) }))
251
+ })
252
+ }
253
+ }
254
+
160
255
  // ── output helpers ──────────────────────────────────────────────────────────
161
256
 
162
257
  function printJson(value) {
@@ -168,6 +263,18 @@ function fail(message) {
168
263
  process.exit(1)
169
264
  }
170
265
 
266
+ const C = process.stdout.isTTY ? {
267
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
268
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
269
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
270
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
271
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
272
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
273
+ } : {
274
+ dim: (s) => s, bold: (s) => s, cyan: (s) => s,
275
+ yellow: (s) => s, red: (s) => s, green: (s) => s,
276
+ }
277
+
171
278
  const HELP = `rterm — command CLI for the RTerm / neuralOS backend gateway
172
279
 
173
280
  Usage:
@@ -182,10 +289,23 @@ Usage:
182
289
  rterm run <tabIdOrName> <command> Run a command in a terminal tab (waits)
183
290
  rterm fleet <tab1,tab2,...> <command> Run a command on many tabs at once
184
291
  rterm sessions List chat sessions
292
+ rterm chat Interactive persistent chat (REPL)
185
293
  rterm chat <sessionId> <message> Send a message to the agent (blocking)
186
294
  rterm dashboard Print the live dashboard state
187
295
  rterm metrics [--format prometheus] Host metrics
188
296
 
297
+ Interactive chat slash commands:
298
+ /new Start a fresh session
299
+ /sessions List sessions (pick one to resume)
300
+ /rename <title> Rename the current session
301
+ /branch Branch from the last assistant message
302
+ /export [--simple] Export this session as markdown
303
+ /search <query> Full-text search across ALL sessions
304
+ /stop Stop the running agent task
305
+ /verbose Toggle raw event display
306
+ /help This list
307
+ /exit Leave the chat (session is kept server-side)
308
+
189
309
  Options:
190
310
  --url ws://host:port Gateway URL (default ${DEFAULT_URL}, env RTERM_URL)
191
311
  --token <token> Access token (env RTERM_TOKEN; non-localhost requires one)
@@ -302,6 +422,486 @@ async function runInTab(client, tabIdOrName, commandText) {
302
422
  return stripAnsi(output).trimEnd()
303
423
  }
304
424
 
425
+ // ── chat state (session resume) ─────────────────────────────────────────────
426
+
427
+ function loadChatState() {
428
+ try {
429
+ if (existsSync(STATE_FILE)) {
430
+ const parsed = JSON.parse(readFileSync(STATE_FILE, 'utf8'))
431
+ if (parsed && typeof parsed === 'object') return parsed
432
+ }
433
+ } catch { /* corrupted state → start fresh */ }
434
+ return {}
435
+ }
436
+
437
+ function saveChatState(patch) {
438
+ try {
439
+ mkdirSync(STATE_DIR, { recursive: true })
440
+ const next = { ...loadChatState(), ...patch }
441
+ writeFileSync(STATE_FILE, JSON.stringify(next, null, 2))
442
+ } catch { /* best-effort */ }
443
+ }
444
+
445
+ // ── interactive chat ────────────────────────────────────────────────────────
446
+
447
+ /** Extract {sessionId, event} from a gateway:event frame (null otherwise). */
448
+ function extractAgentEvent(frame) {
449
+ if (frame?.type !== 'gateway:event') return null
450
+ const p = frame.payload
451
+ if (p?.type !== 'agent:event') return null
452
+ if (!p.payload || typeof p.payload !== 'object') return null
453
+ return { sessionId: p.sessionId, event: p.payload }
454
+ }
455
+
456
+ function shortId(id) {
457
+ return typeof id === 'string' && id.length > 10 ? `${id.slice(0, 8)}…` : String(id)
458
+ }
459
+
460
+ class InteractiveChat {
461
+ constructor(client, flags) {
462
+ this.client = client
463
+ this.flags = flags || {}
464
+ this.sessionId = null
465
+ this.verbose = this.flags.verbose === true
466
+ this.turnActive = false
467
+ this.turnResolve = null
468
+ this.currentSayId = null
469
+ this.sayOpen = false
470
+ this.lastAssistantMessageId = null
471
+ this.unsubscribe = null
472
+ this.rl = null
473
+ }
474
+
475
+ async start() {
476
+ const state = loadChatState()
477
+ const requested = typeof this.flags.session === 'string' && this.flags.session
478
+ ? this.flags.session
479
+ : (state.lastSessionId || null)
480
+
481
+ if (requested) {
482
+ const ok = await this.tryResume(requested)
483
+ if (!ok) console.log(C.dim(`(saved session ${shortId(requested)} no longer exists — starting fresh)`))
484
+ }
485
+ if (!this.sessionId) {
486
+ await this.newSession()
487
+ }
488
+
489
+ this.unsubscribe = this.client.onEvent((frame) => this.handleFrame(frame))
490
+
491
+ console.log(C.dim(`Connected to ${this.client.url} — session ${shortId(this.sessionId)}`))
492
+ console.log(C.dim('Type a message, or /help for commands. /exit to leave.\n'))
493
+ await this.printHistory()
494
+ await this.repl()
495
+ }
496
+
497
+ async tryResume(sessionId) {
498
+ try {
499
+ const result = await this.client.call('session:get', { sessionId })
500
+ if (result?.session?.id || result?.session?.sessionId) {
501
+ this.sessionId = sessionId
502
+ return true
503
+ }
504
+ return false
505
+ } catch {
506
+ return false
507
+ }
508
+ }
509
+
510
+ async newSession() {
511
+ const result = await this.client.call('gateway:createSession')
512
+ this.sessionId = result?.sessionId
513
+ if (!this.sessionId) throw new Error('gateway:createSession returned no sessionId')
514
+ saveChatState({ lastSessionId: this.sessionId, lastUrl: this.client.url })
515
+ }
516
+
517
+ async switchSession(sessionId) {
518
+ this.sessionId = sessionId
519
+ saveChatState({ lastSessionId: sessionId, lastUrl: this.client.url })
520
+ console.log(C.dim(`\n── switched to session ${shortId(sessionId)} ──`))
521
+ await this.printHistory()
522
+ }
523
+
524
+ async printHistory() {
525
+ let messages = []
526
+ try {
527
+ messages = await this.client.call('agent:getUiMessages', { id: this.sessionId })
528
+ if (!Array.isArray(messages)) messages = []
529
+ } catch {
530
+ return // history bridge unavailable — fine on a fresh session
531
+ }
532
+ if (messages.length === 0) {
533
+ console.log(C.dim('(new session — no history yet)'))
534
+ return
535
+ }
536
+ console.log(C.dim(`── resuming (${messages.length} messages) ──`))
537
+ for (const m of messages) {
538
+ const role = m.role === 'user' ? 'you' : m.role === 'assistant' ? 'assistant' : m.role
539
+ const text = typeof m.content === 'string' ? m.content : ''
540
+ if (!text.trim()) continue
541
+ if (m.streaming) continue // never persisted as streaming in practice
542
+ console.log(`${C.bold(C.cyan(role))}> ${text.length > 2000 ? `${text.slice(0, 2000)}…` : text}`)
543
+ }
544
+ const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant' && m.id)
545
+ this.lastAssistantMessageId = lastAssistant?.id || null
546
+ console.log(C.dim('── end of history ──\n'))
547
+ }
548
+
549
+ handleFrame(frame) {
550
+ const extracted = extractAgentEvent(frame)
551
+ if (!extracted || extracted.sessionId !== this.sessionId) return
552
+ const ev = extracted.event
553
+ if (this.verbose) {
554
+ console.log(C.dim(` [event] ${JSON.stringify(ev).slice(0, 300)}`))
555
+ }
556
+ switch (ev.type) {
557
+ case 'say': {
558
+ this.renderSay(ev)
559
+ break
560
+ }
561
+ case 'user_input':
562
+ break // we echo input locally
563
+ case 'command_started': {
564
+ this.closeSay()
565
+ console.log(C.dim(`⚙ ${ev.command || ev.toolName || 'running…'}`))
566
+ break
567
+ }
568
+ case 'command_finished': {
569
+ this.closeSay()
570
+ const ok = ev.exitCode === undefined || ev.exitCode === 0
571
+ console.log(C.dim(`⚙ done${ev.exitCode !== undefined ? ` (exit ${ev.exitCode})` : ''}${ok ? '' : ' ✗'}`))
572
+ break
573
+ }
574
+ case 'sub_tool_started': {
575
+ this.closeSay()
576
+ process.stdout.write(C.dim(`· ${ev.title || ev.toolName || 'thinking'} `))
577
+ break
578
+ }
579
+ case 'sub_tool_delta': {
580
+ if (typeof ev.outputDelta === 'string') process.stdout.write(C.dim(ev.outputDelta))
581
+ break
582
+ }
583
+ case 'sub_tool_finished': {
584
+ process.stdout.write('\n')
585
+ break
586
+ }
587
+ case 'alert': {
588
+ this.closeSay()
589
+ console.log(C.yellow(`⚠ ${ev.message || ''}`))
590
+ break
591
+ }
592
+ case 'error': {
593
+ this.closeSay()
594
+ console.log(C.red(`✗ ${ev.message || ev.error || 'agent error'}`))
595
+ break
596
+ }
597
+ case 'command_ask': {
598
+ this.closeSay()
599
+ void this.handleApproval(ev)
600
+ break
601
+ }
602
+ case 'done': {
603
+ this.closeSay()
604
+ this.lastAssistantMessageId = ev.messageId || this.lastAssistantMessageId
605
+ this.finishTurn()
606
+ break
607
+ }
608
+ default:
609
+ break
610
+ }
611
+ }
612
+
613
+ renderSay(ev) {
614
+ const delta = typeof ev.content === 'string' ? ev.content : (typeof ev.outputDelta === 'string' ? ev.outputDelta : '')
615
+ if (!delta) return
616
+ const id = ev.messageId || null
617
+ if (id && id !== this.currentSayId) {
618
+ if (this.sayOpen) process.stdout.write('\n\n')
619
+ else if (this.currentSayId !== null) process.stdout.write('\n\n')
620
+ process.stdout.write(`${C.bold(C.cyan('assistant'))}> `)
621
+ this.currentSayId = id
622
+ this.sayOpen = true
623
+ }
624
+ process.stdout.write(delta)
625
+ }
626
+
627
+ closeSay() {
628
+ if (this.sayOpen) {
629
+ process.stdout.write('\n')
630
+ this.sayOpen = false
631
+ }
632
+ }
633
+
634
+ async handleApproval(ev) {
635
+ const command = ev.command || ''
636
+ const toolName = ev.toolName || 'Command'
637
+ this.closeSay()
638
+ console.log(C.yellow(`\n⏸ approval needed — ${toolName}:`))
639
+ console.log(C.yellow(` ${command}`))
640
+ // The turn is active (readline paused for the streaming turn) — resume
641
+ // input so the user can actually answer; otherwise this deadlocks.
642
+ this.rl.resume()
643
+ process.stdout.write(C.bold('allow? [y/N] '))
644
+ this.pendingApproval = {
645
+ approvalId: ev.approvalId,
646
+ resolve: async (answer) => {
647
+ const trimmed = (answer || '').trim().toLowerCase()
648
+ const decision = trimmed === 'y' || trimmed === 'yes' ? 'allow' : 'deny'
649
+ try {
650
+ await this.client.call('agent:replyCommandApproval', { approvalId: ev.approvalId, decision })
651
+ console.log(C.dim(decision === 'allow' ? '(allowed)' : '(denied)'))
652
+ } catch (error) {
653
+ console.log(C.red(`approval reply failed: ${errorMessage(error)}`))
654
+ }
655
+ this.pendingApproval = null
656
+ },
657
+ }
658
+ }
659
+
660
+ finishTurn() {
661
+ if (this.turnResolve) {
662
+ const r = this.turnResolve
663
+ this.turnResolve = null
664
+ r()
665
+ }
666
+ // EOF arrived while the turn was streaming → shut down now that it's done.
667
+ if (this.stdinClosed && !this.quitting) this.shutdown()
668
+ }
669
+
670
+ async runTurn(userInput) {
671
+ this.turnActive = true
672
+ this.currentSayId = null
673
+ this.sayOpen = false
674
+ const turnPromise = new Promise((resolve) => { this.turnResolve = resolve })
675
+ try {
676
+ await this.client.call('agent:startTaskAsync', { sessionId: this.sessionId, userInput })
677
+ } catch (error) {
678
+ this.turnActive = false
679
+ throw error
680
+ }
681
+ await turnPromise
682
+ this.turnActive = false
683
+ }
684
+
685
+ async stopTask() {
686
+ try {
687
+ await this.client.call('agent:stopTask', { sessionId: this.sessionId })
688
+ console.log(C.dim('(stop requested)'))
689
+ } catch (error) {
690
+ console.log(C.red(`stop failed: ${errorMessage(error)}`))
691
+ }
692
+ }
693
+
694
+ async listSessionsPick() {
695
+ const result = await this.client.call('session:list')
696
+ const sessions = Array.isArray(result?.sessions) ? result.sessions : []
697
+ if (sessions.length === 0) {
698
+ console.log(C.dim('(no sessions)'))
699
+ return
700
+ }
701
+ sessions.forEach((s, i) => {
702
+ const title = s.title || s.name || '(untitled)'
703
+ const when = s.updatedAt || s.lastActivity || ''
704
+ console.log(` ${String(i + 1).padStart(3)}. ${shortId(s.id)} ${title}${when ? C.dim(` ${when}`) : ''}`)
705
+ })
706
+ this.pendingPick = {
707
+ resolve: async (answer) => {
708
+ const idx = Number.parseInt((answer || '').trim(), 10)
709
+ if (Number.isInteger(idx) && idx >= 1 && idx <= sessions.length) {
710
+ await this.switchSession(sessions[idx - 1].id)
711
+ }
712
+ this.pendingPick = null
713
+ },
714
+ }
715
+ }
716
+
717
+ async branchFromLast() {
718
+ if (!this.lastAssistantMessageId) {
719
+ console.log(C.dim('(no assistant message to branch from yet)'))
720
+ return
721
+ }
722
+ try {
723
+ const result = await this.client.call('agent:branchFromMessage', {
724
+ sessionId: this.sessionId,
725
+ messageId: this.lastAssistantMessageId,
726
+ })
727
+ const newId = result?.sessionId || result?.id
728
+ if (newId) await this.switchSession(newId)
729
+ else console.log(C.dim('(branch created — see /sessions)'))
730
+ } catch (error) {
731
+ console.log(C.red(`branch failed: ${errorMessage(error)}`))
732
+ }
733
+ }
734
+
735
+ async exportSession(mode) {
736
+ try {
737
+ const result = await this.client.call('agent:exportHistory', { sessionId: this.sessionId, mode })
738
+ if (typeof result === 'string') console.log(result)
739
+ else if (typeof result?.content === 'string') console.log(result.content)
740
+ else if (typeof result?.markdown === 'string') console.log(result.markdown)
741
+ else printJson(result)
742
+ } catch (error) {
743
+ console.log(C.red(`export failed: ${errorMessage(error)}`))
744
+ }
745
+ }
746
+
747
+ async searchHistory(query) {
748
+ try {
749
+ const result = await this.client.call('history:search', { query })
750
+ printJson(result)
751
+ } catch (error) {
752
+ console.log(C.red(`search failed: ${errorMessage(error)}`))
753
+ }
754
+ }
755
+
756
+ async handleSlash(line) {
757
+ const [cmd, ...rest] = line.slice(1).split(/\s+/)
758
+ const arg = rest.join(' ')
759
+ switch ((cmd || '').toLowerCase()) {
760
+ case 'new': {
761
+ await this.newSession()
762
+ console.log(C.dim(`── new session ${shortId(this.sessionId)} ──`))
763
+ return true
764
+ }
765
+ case 'sessions':
766
+ await this.listSessionsPick()
767
+ return true
768
+ case 'rename': {
769
+ if (!arg) { console.log(C.dim('usage: /rename <title>')); return true }
770
+ try {
771
+ await this.client.call('agent:renameSession', { sessionId: this.sessionId, newTitle: arg })
772
+ console.log(C.dim(`renamed to "${arg}"`))
773
+ } catch (error) { console.log(C.red(errorMessage(error))) }
774
+ return true
775
+ }
776
+ case 'branch':
777
+ await this.branchFromLast()
778
+ return true
779
+ case 'export':
780
+ await this.exportSession(this.flags.simple || arg.includes('--simple') ? 'simple' : 'detailed')
781
+ return true
782
+ case 'search':
783
+ if (!arg) { console.log(C.dim('usage: /search <query>')); return true }
784
+ await this.searchHistory(arg)
785
+ return true
786
+ case 'stop':
787
+ await this.stopTask()
788
+ return true
789
+ case 'verbose':
790
+ this.verbose = !this.verbose
791
+ console.log(C.dim(`verbose ${this.verbose ? 'on' : 'off'}`))
792
+ return true
793
+ case 'help':
794
+ console.log(HELP.split('Interactive chat slash commands:')[1]?.split('Options:')[0]?.trim() || 'see /exit')
795
+ return true
796
+ case 'exit':
797
+ case 'quit':
798
+ case 'q':
799
+ return false
800
+ default:
801
+ console.log(C.dim(`unknown command "/${cmd}" — /help for the list`))
802
+ return true
803
+ }
804
+ }
805
+
806
+ async repl() {
807
+ // Event-driven readline (NOT readline/promises question()): one 'line'
808
+ // handler dispatches by input state (approval → pick → command/chat).
809
+ // This works identically for a TTY and piped stdin, and never deadlocks.
810
+ this.rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: `${C.bold(C.green('you'))}> ` })
811
+ this.pendingApproval = null
812
+ this.pendingPick = null
813
+ this.quitting = false
814
+
815
+ this.rl.on('line', (line) => {
816
+ void this.onLine(line)
817
+ })
818
+ this.rl.on('close', () => {
819
+ // stdin EOF (piped input exhausted, Ctrl-D, or Ctrl-C on some platforms).
820
+ // If a turn is streaming, let it finish first (finishTurn shuts down);
821
+ // otherwise drain gracefully — NEVER process.exit() here, it would race
822
+ // in-flight async line handlers and truncate pending stdout writes.
823
+ this.stdinClosed = true
824
+ if (!this.turnActive) this.shutdown()
825
+ })
826
+ this.rl.prompt()
827
+ // Resolve only when the REPL shuts down — keeps main() alive.
828
+ return new Promise((resolve) => { this.replDone = resolve })
829
+ }
830
+
831
+ shutdown() {
832
+ if (this.quitting) return
833
+ this.quitting = true
834
+ this.unsubscribe?.()
835
+ console.log(C.dim(`\nsession ${shortId(this.sessionId)} kept server-side — rerun "rterm chat" to resume.`))
836
+ try { this.rl?.close() } catch { /* ignore */ }
837
+ try { this.client.ws?.close?.() } catch { /* ignore */ }
838
+ this.replDone?.()
839
+ }
840
+
841
+ async onLine(line) {
842
+ if (this.quitting) return
843
+ const trimmed = line.trim()
844
+
845
+ // 1. Pending approval prompt captures the next line.
846
+ if (this.pendingApproval) {
847
+ const resolver = this.pendingApproval.resolve
848
+ await resolver(trimmed)
849
+ this.rl.prompt()
850
+ return
851
+ }
852
+ // 2. Pending session-pick prompt captures the next line.
853
+ if (this.pendingPick) {
854
+ const resolver = this.pendingPick.resolve
855
+ await resolver(trimmed)
856
+ this.rl.prompt()
857
+ return
858
+ }
859
+ // 3. Slash commands.
860
+ if (trimmed.startsWith('/')) {
861
+ const keepGoing = await this.handleSlash(trimmed)
862
+ if (!keepGoing) {
863
+ this.shutdown()
864
+ return
865
+ }
866
+ this.rl.prompt()
867
+ return
868
+ }
869
+ // 4. Empty line → just re-prompt.
870
+ if (!trimmed) {
871
+ this.rl.prompt()
872
+ return
873
+ }
874
+ // 5. A chat turn. The prompt is suppressed while the agent streams;
875
+ // the 'done' event re-prompts via finishTurn().
876
+ if (this.turnActive) {
877
+ console.log(C.dim('(agent is still running — /stop to interrupt)'))
878
+ this.rl.prompt()
879
+ return
880
+ }
881
+ this.rl.pause()
882
+ try {
883
+ await this.runTurn(trimmed)
884
+ } catch (error) {
885
+ console.log(C.red(`Error: ${errorMessage(error)}`))
886
+ if (!this.client.connected) {
887
+ try {
888
+ await this.client.reconnect()
889
+ this.unsubscribe?.()
890
+ this.unsubscribe = this.client.onEvent((frame) => this.handleFrame(frame))
891
+ console.log(C.dim('reconnected.'))
892
+ } catch {
893
+ console.log(C.red('reconnect failed — exiting.'))
894
+ this.quitting = true
895
+ this.rl.close()
896
+ process.exit(1)
897
+ }
898
+ }
899
+ }
900
+ this.rl.resume()
901
+ this.rl.prompt()
902
+ }
903
+ }
904
+
305
905
  // ── commands ────────────────────────────────────────────────────────────────
306
906
 
307
907
  async function main() {
@@ -420,7 +1020,16 @@ async function main() {
420
1020
  case 'chat': {
421
1021
  const sessionId = positional[1]
422
1022
  const message = positional.slice(2).join(' ')
423
- if (!sessionId || !message) fail('chat needs: rterm chat <sessionId> <message>')
1023
+ if (!sessionId) {
1024
+ // Interactive persistent chat (the desktop-style experience).
1025
+ // chat.start() resolves only when the REPL shuts down.
1026
+ const pclient = new PersistentClient(url, token)
1027
+ await pclient.connect()
1028
+ const chat = new InteractiveChat(pclient, flags)
1029
+ await chat.start()
1030
+ process.exit(0)
1031
+ }
1032
+ if (!message) fail('chat needs: rterm chat <sessionId> <message> (or "rterm chat" for interactive mode)')
424
1033
  printJson(await client.call('agent:startTask', { sessionId, userInput: message }))
425
1034
  break
426
1035
  }
Binary file