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 +50 -2
- package/package.json +28 -30
- package/rterm-cli.mjs +611 -2
- package/rterm-cli-3.4.1.tgz +0 -0
package/README.md
CHANGED
|
@@ -7,11 +7,13 @@ no dependencies.
|
|
|
7
7
|
## Install / run
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
#
|
|
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
|
-
"description": "rterm
|
|
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
|
-
"
|
|
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
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
20
|
+
"terminal",
|
|
21
|
+
"ssh",
|
|
22
|
+
"ai",
|
|
23
|
+
"agent",
|
|
24
|
+
"cli",
|
|
25
25
|
"chatops",
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"closed-loop-remediation",
|
|
29
|
-
"mcp"
|
|
26
|
+
"fde",
|
|
27
|
+
"agentic-ai"
|
|
30
28
|
],
|
|
31
|
-
"license": "
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
}
|
package/rterm-cli-3.4.1.tgz
DELETED
|
Binary file
|