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 +50 -2
- package/package.json +28 -30
- package/rterm-cli.mjs +633 -4
- package/rterm-cli-3.4.4.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.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
|
-
"
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
|
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
|
}
|
package/rterm-cli-3.4.4.tgz
DELETED
|
Binary file
|