@anusornneal/chat-relay 0.10.2 → 0.11.1
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/agent/connection-state.mjs +1 -1
- package/agent/local-agent.mjs +63 -12
- package/agent/toolcall-summary.mjs +148 -0
- package/cli/index.mjs +4 -0
- package/cli/remote.mjs +16 -5
- package/cli/tui.mjs +215 -0
- package/package.json +1 -1
|
@@ -30,7 +30,7 @@ export class AgentConnectionState {
|
|
|
30
30
|
constructor(options = {}) {
|
|
31
31
|
this.baseReconnectMs = boundedInteger(options.baseReconnectMs, 2000, 100, 60_000);
|
|
32
32
|
this.maxReconnectMs = boundedInteger(options.maxReconnectMs, 30_000, this.baseReconnectMs, 120_000);
|
|
33
|
-
this.heartbeatMs = boundedInteger(options.heartbeatMs,
|
|
33
|
+
this.heartbeatMs = boundedInteger(options.heartbeatMs, 30_000, 5_000, 60_000);
|
|
34
34
|
this.processStartedAt = new Date().toISOString();
|
|
35
35
|
this.state = "starting";
|
|
36
36
|
this.connectedAt = null;
|
package/agent/local-agent.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { CapabilityScheduler } from "./capability-scheduler.mjs";
|
|
|
7
7
|
import { AgentConnectionState } from "./connection-state.mjs";
|
|
8
8
|
import { buildAgentHello } from "./protocol.mjs";
|
|
9
9
|
import { AgentLifecycle, AGENT_RESTART_EXIT_CODE } from "./lifecycle.mjs";
|
|
10
|
+
import { humanizeToolCall } from "./toolcall-summary.mjs";
|
|
10
11
|
import {
|
|
11
12
|
HeartbeatAckWatchdog,
|
|
12
13
|
HEARTBEAT_ACK_TIMEOUT_REASON,
|
|
@@ -19,6 +20,24 @@ const agentName = process.env.AGENT_NAME || agentId;
|
|
|
19
20
|
const reconnectMs = Number(process.env.RECONNECT_MS ?? 2000);
|
|
20
21
|
const terminalEnabled = process.env.TERMINAL_ENABLED === "1";
|
|
21
22
|
const desktopEnabled = process.env.DESKTOP_ENABLED === "1";
|
|
23
|
+
const ipcUiEnabled = process.env.CHAT_RELAY_UI === "ipc";
|
|
24
|
+
|
|
25
|
+
function emitUi(event, payload = {}) {
|
|
26
|
+
if (!ipcUiEnabled || typeof process.send !== "function") return;
|
|
27
|
+
try { process.send({ type: "chat-relay-ui", event, at: new Date().toISOString(), ...payload }); } catch {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function plainLog(...args) {
|
|
31
|
+
if (!ipcUiEnabled) console.log(...args);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function plainError(...args) {
|
|
35
|
+
if (!ipcUiEnabled) {
|
|
36
|
+
console.error(...args);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
emitUi("system", { level: "error", message: args.map((item) => String(item)).join(" ") });
|
|
40
|
+
}
|
|
22
41
|
const desktopAdapter = createDesktopPlatformAdapter({ platform: process.platform });
|
|
23
42
|
const agentHello = buildAgentHello({
|
|
24
43
|
platform: desktopAdapter.platform,
|
|
@@ -78,7 +97,7 @@ const TRANSPORT_PING_MS = Math.max(5000, Math.min(Number(process.env.AGENT_TRANS
|
|
|
78
97
|
const TRANSPORT_PONG_TIMEOUT_MS = Math.max(3000, Math.min(Number(process.env.AGENT_TRANSPORT_PONG_TIMEOUT_MS) || 8000, 30000));
|
|
79
98
|
const FAST_RECONNECT_MS = Math.max(100, Math.min(Number(process.env.AGENT_FAST_RECONNECT_MS) || 250, 5000));
|
|
80
99
|
const FAST_RECONNECT_STABLE_MS = Math.max(5000, Math.min(Number(process.env.AGENT_FAST_RECONNECT_STABLE_MS) || 30000, 300000));
|
|
81
|
-
const HEARTBEAT_ACK_TIMEOUT_MS = Math.max(15000, Math.min(Number(process.env.AGENT_HEARTBEAT_ACK_TIMEOUT_MS) ||
|
|
100
|
+
const HEARTBEAT_ACK_TIMEOUT_MS = Math.max(15000, Math.min(Number(process.env.AGENT_HEARTBEAT_ACK_TIMEOUT_MS) || 90000, 180000));
|
|
82
101
|
const heartbeatAckWatchdog = new HeartbeatAckWatchdog({
|
|
83
102
|
timeoutMs: HEARTBEAT_ACK_TIMEOUT_MS,
|
|
84
103
|
onTimeout: () => {
|
|
@@ -104,7 +123,7 @@ function currentWorkSummary() {
|
|
|
104
123
|
const lifecycle = new AgentLifecycle({ workSummary: currentWorkSummary });
|
|
105
124
|
|
|
106
125
|
if (!relayUrl || !agentToken) {
|
|
107
|
-
|
|
126
|
+
plainError("RELAY_URL and AGENT_TOKEN are required");
|
|
108
127
|
process.exit(1);
|
|
109
128
|
}
|
|
110
129
|
|
|
@@ -358,8 +377,15 @@ function scheduleReconnect(code, reason) {
|
|
|
358
377
|
const isTransientAbnormalClose = Number(code) === 1006 && stableConnectionMs >= FAST_RECONNECT_STABLE_MS;
|
|
359
378
|
const delay = isTransientAbnormalClose ? FAST_RECONNECT_MS : connectionState.nextDelay();
|
|
360
379
|
connectionState.scheduleReconnect(delay);
|
|
380
|
+
emitUi("connection", {
|
|
381
|
+
state: "reconnecting",
|
|
382
|
+
reconnects: connectionState.snapshot().reconnectCount,
|
|
383
|
+
delayMs: delay,
|
|
384
|
+
closeCode: code ?? null,
|
|
385
|
+
reason: reason || "socket_closed",
|
|
386
|
+
});
|
|
361
387
|
clearReconnectTimer();
|
|
362
|
-
|
|
388
|
+
plainLog(`Reconnect scheduled in ${delay}ms`);
|
|
363
389
|
reconnectTimer = setTimeout(() => {
|
|
364
390
|
reconnectTimer = null;
|
|
365
391
|
connect();
|
|
@@ -380,7 +406,7 @@ function requireProtocolUpdate(details = {}) {
|
|
|
380
406
|
desktop.close();
|
|
381
407
|
const expected = details.expectedProtocolVersion ?? "current";
|
|
382
408
|
const received = details.receivedProtocolVersion ?? agentHello.protocolVersion;
|
|
383
|
-
|
|
409
|
+
plainError(`Agent protocol is incompatible (local=${received}, relay=${expected}). Update Chat Relay, then reconnect.`);
|
|
384
410
|
setImmediate(() => process.exit(3));
|
|
385
411
|
}
|
|
386
412
|
|
|
@@ -395,8 +421,8 @@ function requireReauthorization(reason = "credential_revoked") {
|
|
|
395
421
|
try { socket?.terminate(); } catch {}
|
|
396
422
|
terminals.close();
|
|
397
423
|
desktop.close();
|
|
398
|
-
|
|
399
|
-
|
|
424
|
+
plainError("Agent credential was revoked or rejected.");
|
|
425
|
+
plainError('Recovery: run "chat-relay login --force", then "chat-relay remote".');
|
|
400
426
|
setImmediate(() => process.exit(2));
|
|
401
427
|
}
|
|
402
428
|
|
|
@@ -404,10 +430,14 @@ function connect() {
|
|
|
404
430
|
if (stopping || reauthorizationRequired) return;
|
|
405
431
|
clearReconnectTimer();
|
|
406
432
|
connectionState.markConnecting();
|
|
407
|
-
|
|
408
|
-
|
|
433
|
+
emitUi("connection", {
|
|
434
|
+
state: connectionState.snapshot().reconnectCount > 0 ? "reconnecting" : "connecting",
|
|
435
|
+
reconnects: connectionState.snapshot().reconnectCount,
|
|
436
|
+
});
|
|
437
|
+
plainLog(`Connecting to ${wsUrl}`);
|
|
438
|
+
plainLog(`Terminal access: ${terminalEnabled ? "enabled" : "disabled"}`);
|
|
409
439
|
const desktopConfig = desktop.getConfig();
|
|
410
|
-
|
|
440
|
+
plainLog("Desktop access: " + (!desktopEnabled ? "disabled" : desktopConfig.supported ? "enabled" : `unsupported (${desktopConfig.platform})`));
|
|
411
441
|
|
|
412
442
|
const socket = new WebSocket(wsUrl, {
|
|
413
443
|
headers: { Authorization: `Bearer ${agentToken}` },
|
|
@@ -418,8 +448,9 @@ function connect() {
|
|
|
418
448
|
if (socket !== activeSocket) return;
|
|
419
449
|
socket.send(JSON.stringify(agentHello));
|
|
420
450
|
connectionState.markConnected();
|
|
451
|
+
emitUi("connection", { state: "connected", reconnects: connectionState.snapshot().reconnectCount });
|
|
421
452
|
startHeartbeat(socket);
|
|
422
|
-
|
|
453
|
+
plainLog("Agent connected");
|
|
423
454
|
});
|
|
424
455
|
|
|
425
456
|
socket.on("pong", () => {
|
|
@@ -458,6 +489,8 @@ function connect() {
|
|
|
458
489
|
const action = message.payload && typeof message.payload === "object"
|
|
459
490
|
? String(message.payload.action ?? "unknown")
|
|
460
491
|
: "unknown";
|
|
492
|
+
const callSummary = humanizeToolCall(message.payload);
|
|
493
|
+
emitUi("tool:start", { requestId: message.requestId, action, summary: callSummary });
|
|
461
494
|
let scheduleMeta = { lane: "unknown", queueWaitMs: 0, queueDepthAtStart: 0, activeAtStart: 0 };
|
|
462
495
|
let handlerDurationMs = 0;
|
|
463
496
|
try {
|
|
@@ -474,6 +507,7 @@ function connect() {
|
|
|
474
507
|
recentCalls.push({
|
|
475
508
|
at: new Date().toISOString(),
|
|
476
509
|
action,
|
|
510
|
+
summary: callSummary,
|
|
477
511
|
durationMs: Date.now() - startedAt,
|
|
478
512
|
queueWaitMs: scheduleMeta.queueWaitMs,
|
|
479
513
|
handlerDurationMs,
|
|
@@ -483,6 +517,14 @@ function connect() {
|
|
|
483
517
|
ok: !(result && typeof result === "object" && result.ok === false),
|
|
484
518
|
});
|
|
485
519
|
if (recentCalls.length > 100) recentCalls.shift();
|
|
520
|
+
emitUi("tool:end", {
|
|
521
|
+
requestId: message.requestId,
|
|
522
|
+
action,
|
|
523
|
+
summary: callSummary,
|
|
524
|
+
ok: !(result && typeof result === "object" && result.ok === false),
|
|
525
|
+
durationMs: Date.now() - startedAt,
|
|
526
|
+
...(result && typeof result === "object" && result.ok === false && result.error ? { error: String(result.error) } : {}),
|
|
527
|
+
});
|
|
486
528
|
sendSocketResponse(socket, message.requestId, result, {
|
|
487
529
|
agentQueueWaitMs: scheduleMeta.queueWaitMs,
|
|
488
530
|
agentHandlerMs: handlerDurationMs,
|
|
@@ -492,6 +534,7 @@ function connect() {
|
|
|
492
534
|
recentCalls.push({
|
|
493
535
|
at: new Date().toISOString(),
|
|
494
536
|
action,
|
|
537
|
+
summary: callSummary,
|
|
495
538
|
durationMs: Date.now() - startedAt,
|
|
496
539
|
queueWaitMs: scheduleMeta.queueWaitMs,
|
|
497
540
|
handlerDurationMs,
|
|
@@ -500,6 +543,14 @@ function connect() {
|
|
|
500
543
|
error: error instanceof Error ? error.message : "agent_error",
|
|
501
544
|
});
|
|
502
545
|
if (recentCalls.length > 100) recentCalls.shift();
|
|
546
|
+
emitUi("tool:end", {
|
|
547
|
+
requestId: message.requestId,
|
|
548
|
+
action,
|
|
549
|
+
summary: callSummary,
|
|
550
|
+
ok: false,
|
|
551
|
+
durationMs: Date.now() - startedAt,
|
|
552
|
+
error: error instanceof Error ? error.message : "agent_error",
|
|
553
|
+
});
|
|
503
554
|
sendSocketResponse(socket, message.requestId, {
|
|
504
555
|
ok: false,
|
|
505
556
|
error: error instanceof Error ? error.message : "agent_error",
|
|
@@ -527,7 +578,7 @@ function connect() {
|
|
|
527
578
|
activeSocket = null;
|
|
528
579
|
stopHeartbeat();
|
|
529
580
|
const reasonText = reason.toString();
|
|
530
|
-
|
|
581
|
+
plainLog("Agent disconnected (" + code + ") " + reasonText);
|
|
531
582
|
if (code === 4001 || reasonText === "credential_revoked") {
|
|
532
583
|
requireReauthorization("credential_revoked");
|
|
533
584
|
return;
|
|
@@ -542,7 +593,7 @@ function connect() {
|
|
|
542
593
|
socket.on("error", (error) => {
|
|
543
594
|
if (socket === activeSocket && !stopping && !reauthorizationRequired) {
|
|
544
595
|
connectionState.markSocketError(error.message);
|
|
545
|
-
|
|
596
|
+
plainError("WebSocket error:", error.message);
|
|
546
597
|
}
|
|
547
598
|
});
|
|
548
599
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const MAX_TEXT = 96;
|
|
2
|
+
|
|
3
|
+
function cleanText(value, max = MAX_TEXT) {
|
|
4
|
+
const text = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
5
|
+
if (text.length <= max) return text;
|
|
6
|
+
return text.slice(0, Math.max(0, max - 1)) + "…";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function compactPath(value) {
|
|
10
|
+
const normalized = cleanText(value, 120).replace(/\\/g, "/");
|
|
11
|
+
if (normalized.length <= 72) return normalized || "-";
|
|
12
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
13
|
+
if (parts.length <= 2) return "…" + normalized.slice(-68);
|
|
14
|
+
return "…/" + parts.slice(-3).join("/");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function redactCommand(value) {
|
|
18
|
+
let text = cleanText(value, 220);
|
|
19
|
+
const patterns = [
|
|
20
|
+
/(authorization\s*[:=]\s*bearer\s+)[^\s"']+/ig,
|
|
21
|
+
/((?:token|password|passwd|secret|api[_-]?key|access[_-]?key)\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s;]+)/ig,
|
|
22
|
+
/(--(?:token|password|secret|api-key|access-key)(?:=|\s+))("[^"]*"|'[^']*'|[^\s;]+)/ig,
|
|
23
|
+
];
|
|
24
|
+
for (const pattern of patterns) text = text.replace(pattern, "$1[redacted]");
|
|
25
|
+
return text;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function number(value, fallback = 0) {
|
|
29
|
+
const parsed = Number(value);
|
|
30
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function lineRange(payload) {
|
|
34
|
+
const offset = Math.max(0, number(payload.offset));
|
|
35
|
+
const length = Math.max(1, number(payload.length, 200));
|
|
36
|
+
return `lines ${offset + 1}–${offset + length}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function countKeyboardInput(payload) {
|
|
40
|
+
if (Array.isArray(payload.actions)) return payload.actions.length + " action" + (payload.actions.length === 1 ? "" : "s");
|
|
41
|
+
if (typeof payload.text === "string") return payload.text.length + " chars";
|
|
42
|
+
if (typeof payload.input === "string") return payload.input.length + " chars";
|
|
43
|
+
return "input";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function humanizeToolCall(payload = {}) {
|
|
47
|
+
const action = String(payload?.action || "unknown");
|
|
48
|
+
|
|
49
|
+
switch (action) {
|
|
50
|
+
case "ping":
|
|
51
|
+
return "Ping agent";
|
|
52
|
+
case "agent.config":
|
|
53
|
+
return "Get agent config";
|
|
54
|
+
case "agent.recentCalls":
|
|
55
|
+
return `Get recent tool calls · limit ${number(payload.limit, 50)}`;
|
|
56
|
+
case "agent.lifecycle.status":
|
|
57
|
+
return "Get lifecycle status";
|
|
58
|
+
case "agent.lifecycle.drain":
|
|
59
|
+
return "Drain agent";
|
|
60
|
+
case "agent.lifecycle.resume":
|
|
61
|
+
return "Resume agent";
|
|
62
|
+
case "agent.lifecycle.restart":
|
|
63
|
+
return "Restart agent";
|
|
64
|
+
|
|
65
|
+
case "fs.stat":
|
|
66
|
+
return `Stat ${compactPath(payload.path)}`;
|
|
67
|
+
case "fs.list":
|
|
68
|
+
return `List ${compactPath(payload.path)} · depth ${number(payload.depth)} · limit ${number(payload.limit, 100)}`;
|
|
69
|
+
case "fs.read":
|
|
70
|
+
return `Read ${compactPath(payload.path)} · ${lineRange(payload)}`;
|
|
71
|
+
case "fs.readMany":
|
|
72
|
+
return `Read ${Array.isArray(payload.paths) ? payload.paths.length : 0} files`;
|
|
73
|
+
case "fs.batch":
|
|
74
|
+
return `Filesystem batch · ${Array.isArray(payload.operations) ? payload.operations.length : 0} ops`;
|
|
75
|
+
case "fs.artifact":
|
|
76
|
+
return `Export ${compactPath(payload.path)}`;
|
|
77
|
+
case "fs.write": {
|
|
78
|
+
const bytes = Buffer.byteLength(String(payload.content ?? ""), "utf8");
|
|
79
|
+
return `Write ${compactPath(payload.path)} · ${payload.mode || "rewrite"} · ${bytes} B`;
|
|
80
|
+
}
|
|
81
|
+
case "fs.edit":
|
|
82
|
+
return `Edit ${compactPath(payload.path)} · replace ×${number(payload.expectedReplacements, 1)}`;
|
|
83
|
+
case "fs.mkdir":
|
|
84
|
+
return `Create folder ${compactPath(payload.path)}`;
|
|
85
|
+
case "fs.move":
|
|
86
|
+
return `Move ${compactPath(payload.source)} → ${compactPath(payload.destination)}`;
|
|
87
|
+
case "fs.delete":
|
|
88
|
+
return `Delete ${compactPath(payload.path)}${payload.recursive ? " · recursive" : ""}`;
|
|
89
|
+
case "fs.search.start":
|
|
90
|
+
return `Search "${cleanText(payload.pattern, 56)}" in ${compactPath(payload.path)}`;
|
|
91
|
+
case "fs.search.results":
|
|
92
|
+
return `Read search results · offset ${number(payload.offset)} · ${number(payload.length, 50)} rows`;
|
|
93
|
+
|
|
94
|
+
case "process.list":
|
|
95
|
+
return `List processes${payload.filter ? ` · "${cleanText(payload.filter, 52)}"` : ""}`;
|
|
96
|
+
case "process.kill":
|
|
97
|
+
return `Kill process ${number(payload.pid)}`;
|
|
98
|
+
|
|
99
|
+
case "terminal.exec":
|
|
100
|
+
return `Run ${redactCommand(payload.command)}${payload.cwd ? ` · in ${compactPath(payload.cwd)}` : ""}`;
|
|
101
|
+
case "terminal.start":
|
|
102
|
+
return `Start ${redactCommand(payload.command)}${payload.cwd ? ` · in ${compactPath(payload.cwd)}` : ""}`;
|
|
103
|
+
case "terminal.shell.start":
|
|
104
|
+
return `Start shell${payload.cwd ? ` · in ${compactPath(payload.cwd)}` : ""}`;
|
|
105
|
+
case "terminal.read":
|
|
106
|
+
return `Read terminal ${cleanText(payload.sessionId, 32)} · after #${number(payload.afterSeq)}`;
|
|
107
|
+
case "terminal.write":
|
|
108
|
+
return `Write terminal ${cleanText(payload.sessionId, 32)} · ${String(payload.input ?? "").length} chars`;
|
|
109
|
+
case "terminal.kill":
|
|
110
|
+
return `Stop terminal ${cleanText(payload.sessionId, 32)}`;
|
|
111
|
+
case "terminal.list":
|
|
112
|
+
return "List terminal sessions";
|
|
113
|
+
case "terminal.observability":
|
|
114
|
+
return "Get terminal status";
|
|
115
|
+
case "terminal.batch.start":
|
|
116
|
+
return `Run terminal batch · ${Array.isArray(payload.jobs) ? payload.jobs.length : 0} jobs · concurrency ${number(payload.concurrency, 1)}`;
|
|
117
|
+
case "terminal.batch.status":
|
|
118
|
+
return `Get batch status ${cleanText(payload.batchId, 32)}`;
|
|
119
|
+
case "terminal.batch.read":
|
|
120
|
+
return `Read batch ${cleanText(payload.batchId, 32)}`;
|
|
121
|
+
case "terminal.batch.cancel":
|
|
122
|
+
return `Cancel batch ${cleanText(payload.batchId, 32)}`;
|
|
123
|
+
|
|
124
|
+
case "desktop.screenshot":
|
|
125
|
+
return "Capture screen";
|
|
126
|
+
case "desktop.clipboard.read":
|
|
127
|
+
return "Read clipboard";
|
|
128
|
+
case "desktop.clipboard.write":
|
|
129
|
+
return `Write clipboard · ${String(payload.text ?? payload.content ?? "").length} chars`;
|
|
130
|
+
case "desktop.window.list":
|
|
131
|
+
return "List windows";
|
|
132
|
+
case "desktop.window.focus":
|
|
133
|
+
return `Focus window ${cleanText(payload.title ?? payload.windowId ?? payload.handle ?? "", 64)}`;
|
|
134
|
+
case "desktop.mouse.click": {
|
|
135
|
+
const button = cleanText(payload.button || "left", 12);
|
|
136
|
+
const x = number(payload.x);
|
|
137
|
+
const y = number(payload.y);
|
|
138
|
+
return `${button[0]?.toUpperCase() || "L"}${button.slice(1)} click · ${x},${y}`;
|
|
139
|
+
}
|
|
140
|
+
case "desktop.keyboard.input":
|
|
141
|
+
return `Keyboard input · ${countKeyboardInput(payload)}`;
|
|
142
|
+
case "desktop.step":
|
|
143
|
+
return `Desktop step · ${Array.isArray(payload.actions) ? payload.actions.length : 0} actions`;
|
|
144
|
+
|
|
145
|
+
default:
|
|
146
|
+
return cleanText(action.replace(/[._]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()), 100) || "Tool call";
|
|
147
|
+
}
|
|
148
|
+
}
|
package/cli/index.mjs
CHANGED
|
@@ -11,6 +11,8 @@ function parseOptions(args) {
|
|
|
11
11
|
else if (arg === "--force") options.force = true;
|
|
12
12
|
else if (arg === "--desktop") options.desktopEnabled = true;
|
|
13
13
|
else if (arg === "--no-desktop") options.desktopEnabled = false;
|
|
14
|
+
else if (arg === "--plain") options.tuiEnabled = false;
|
|
15
|
+
else if (arg === "--tui") options.tuiEnabled = true;
|
|
14
16
|
else if (arg === "--relay") options.relayUrl = args[++index];
|
|
15
17
|
else if (arg.startsWith("--relay=")) options.relayUrl = arg.slice(8);
|
|
16
18
|
else if (arg === "--root") options.allowedRoot = args[++index];
|
|
@@ -44,6 +46,8 @@ Options:
|
|
|
44
46
|
--agent-id <id> Stable agent identifier
|
|
45
47
|
--desktop Enable Windows desktop screenshot/input access
|
|
46
48
|
--no-desktop Disable desktop access (default)
|
|
49
|
+
--plain Use legacy line-by-line logs
|
|
50
|
+
--tui Force interactive terminal UI
|
|
47
51
|
--no-open Do not open the browser automatically
|
|
48
52
|
--force Force a new login
|
|
49
53
|
|
package/cli/remote.mjs
CHANGED
|
@@ -4,6 +4,8 @@ import path from "node:path";
|
|
|
4
4
|
import { loadConfig, saveConfig } from "./config.mjs";
|
|
5
5
|
import { login, requestJson } from "./device-login.mjs";
|
|
6
6
|
import { acquireRunnerOwnership, RunnerAlreadyActiveError } from "./runner-ownership.mjs";
|
|
7
|
+
import { getAgentVersion } from "../agent/protocol.mjs";
|
|
8
|
+
import { RemoteTui, shouldUseTui } from "./tui.mjs";
|
|
7
9
|
|
|
8
10
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
11
|
let stopping = false;
|
|
@@ -55,10 +57,11 @@ function printSummary(config) {
|
|
|
55
57
|
console.log("");
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
function spawnAgent(config) {
|
|
60
|
+
function spawnAgent(config, { tui = null } = {}) {
|
|
59
61
|
const agentPath = path.join(packageRoot, "agent", "local-agent.mjs");
|
|
60
62
|
const cwd = String(config.allowedRoots || process.cwd()).split(";")[0] || process.cwd();
|
|
61
|
-
|
|
63
|
+
const useTui = Boolean(tui);
|
|
64
|
+
const child = spawn(process.execPath, [agentPath], {
|
|
62
65
|
cwd,
|
|
63
66
|
env: {
|
|
64
67
|
...process.env,
|
|
@@ -69,10 +72,13 @@ function spawnAgent(config) {
|
|
|
69
72
|
TERMINAL_ENABLED: config.terminalEnabled === false ? "0" : "1",
|
|
70
73
|
DESKTOP_ENABLED: config.desktopEnabled === true ? "1" : "0",
|
|
71
74
|
ALLOWED_ROOTS: config.allowedRoots,
|
|
75
|
+
CHAT_RELAY_UI: useTui ? "ipc" : "plain",
|
|
72
76
|
},
|
|
73
|
-
stdio: "inherit",
|
|
77
|
+
stdio: useTui ? ["inherit", "inherit", "inherit", "ipc"] : "inherit",
|
|
74
78
|
windowsHide: false,
|
|
75
79
|
});
|
|
80
|
+
if (useTui) child.on("message", (message) => tui.handleMessage(message));
|
|
81
|
+
return child;
|
|
76
82
|
}
|
|
77
83
|
|
|
78
84
|
export async function remote(options = {}) {
|
|
@@ -91,6 +97,7 @@ export async function remote(options = {}) {
|
|
|
91
97
|
console.error(error.message);
|
|
92
98
|
return 1;
|
|
93
99
|
}
|
|
100
|
+
let tui = null;
|
|
94
101
|
try {
|
|
95
102
|
if (options.allowedRoot && options.allowedRoot !== config.allowedRoots) {
|
|
96
103
|
config = { ...config, allowedRoots: options.allowedRoot };
|
|
@@ -101,10 +108,13 @@ export async function remote(options = {}) {
|
|
|
101
108
|
saveConfig(config);
|
|
102
109
|
}
|
|
103
110
|
|
|
104
|
-
|
|
111
|
+
const useTui = shouldUseTui(options);
|
|
112
|
+
tui = useTui ? new RemoteTui({ config, version: getAgentVersion() }) : null;
|
|
113
|
+
if (tui) tui.start();
|
|
114
|
+
else printSummary(config);
|
|
105
115
|
|
|
106
116
|
while (!stopping) {
|
|
107
|
-
const child = spawnAgent(config);
|
|
117
|
+
const child = spawnAgent(config, { tui });
|
|
108
118
|
|
|
109
119
|
const stop = () => {
|
|
110
120
|
stopping = true;
|
|
@@ -145,6 +155,7 @@ export async function remote(options = {}) {
|
|
|
145
155
|
}
|
|
146
156
|
}
|
|
147
157
|
} finally {
|
|
158
|
+
try { tui?.stop?.(); } catch {}
|
|
148
159
|
await ownership.release();
|
|
149
160
|
}
|
|
150
161
|
}
|
package/cli/tui.mjs
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { humanizeToolCall } from "../agent/toolcall-summary.mjs";
|
|
2
|
+
|
|
3
|
+
const ESC = "\x1b[";
|
|
4
|
+
const LABEL_WIDTH = 12;
|
|
5
|
+
const GAP = 3;
|
|
6
|
+
const MIN_FRAME_WIDTH = 60;
|
|
7
|
+
const MAX_FRAME_WIDTH = 179;
|
|
8
|
+
const FRAME_FIXED_ROWS = 11;
|
|
9
|
+
const FRAME_BOTTOM_MARGIN = 1;
|
|
10
|
+
|
|
11
|
+
function clamp(value, min, max) {
|
|
12
|
+
return Math.max(min, Math.min(max, value));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function truncate(value, width) {
|
|
16
|
+
const text = String(value ?? "");
|
|
17
|
+
if (width <= 0) return "";
|
|
18
|
+
if (text.length <= width) return text;
|
|
19
|
+
if (width === 1) return "…";
|
|
20
|
+
return text.slice(0, width - 1) + "…";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function field(label, value, width) {
|
|
24
|
+
const valueWidth = Math.max(1, width - LABEL_WIDTH);
|
|
25
|
+
return truncate(String(label).padEnd(LABEL_WIDTH) + truncate(value, valueWidth), width).padEnd(width);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function formatTwoColumnHeader(state, width = 110) {
|
|
29
|
+
const usable = clamp(Number(width) || 110, MIN_FRAME_WIDTH, MAX_FRAME_WIDTH);
|
|
30
|
+
const leftWidth = Math.floor((usable - GAP) / 2);
|
|
31
|
+
const rightWidth = usable - GAP - leftWidth;
|
|
32
|
+
const rows = [
|
|
33
|
+
["Account", state.account || "-", "Agent", state.agent || "-"],
|
|
34
|
+
["Status", statusText(state.status), "Relay", state.relay || "-"],
|
|
35
|
+
["Uptime", formatUptime(state.uptimeMs), "Reconnects", String(state.reconnects ?? 0)],
|
|
36
|
+
["Terminal", state.terminal ? "enabled" : "disabled", "Desktop", state.desktop ? "enabled" : "disabled"],
|
|
37
|
+
];
|
|
38
|
+
return rows
|
|
39
|
+
.map(([leftLabel, leftValue, rightLabel, rightValue]) =>
|
|
40
|
+
field(leftLabel, leftValue, leftWidth) + " ".repeat(GAP) + field(rightLabel, rightValue, rightWidth).trimEnd(),
|
|
41
|
+
)
|
|
42
|
+
.join("\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function statusText(value) {
|
|
46
|
+
switch (String(value || "").toLowerCase()) {
|
|
47
|
+
case "connected": return "● Connected";
|
|
48
|
+
case "connecting": return "◐ Connecting";
|
|
49
|
+
case "reconnecting": return "◐ Reconnecting";
|
|
50
|
+
case "stopping": return "○ Stopping";
|
|
51
|
+
case "offline": return "○ Offline";
|
|
52
|
+
default: return "○ Starting";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function formatUptime(ms) {
|
|
57
|
+
const totalSeconds = Math.max(0, Math.floor(Number(ms || 0) / 1000));
|
|
58
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
59
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
60
|
+
const seconds = totalSeconds % 60;
|
|
61
|
+
return [hours, minutes, seconds].map((value) => String(value).padStart(2, "0")).join(":");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function formatDuration(ms, status) {
|
|
65
|
+
if (status === "running") return "running";
|
|
66
|
+
const value = Math.max(0, Number(ms) || 0);
|
|
67
|
+
if (value < 1000) return Math.round(value) + "ms";
|
|
68
|
+
return (value / 1000).toFixed(value < 10000 ? 1 : 0) + "s";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function clock(value) {
|
|
72
|
+
const date = value ? new Date(value) : new Date();
|
|
73
|
+
if (Number.isNaN(date.getTime())) return "--:--:--";
|
|
74
|
+
return date.toLocaleTimeString("en-GB", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function formatTransactionRow(item, width = 110) {
|
|
78
|
+
const usable = clamp(Number(width) || 110, 60, 180);
|
|
79
|
+
const status = item.status === "running" ? "●" : item.ok === false ? "✕" : "✓";
|
|
80
|
+
const prefix = `${clock(item.at)} ${status} `;
|
|
81
|
+
const duration = formatDuration(item.durationMs, item.status);
|
|
82
|
+
const suffix = " " + duration.padStart(8);
|
|
83
|
+
const summaryWidth = Math.max(12, usable - prefix.length - suffix.length);
|
|
84
|
+
return prefix + truncate(item.summary || item.action || "Tool call", summaryWidth).padEnd(summaryWidth) + suffix;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalizeRelay(value) {
|
|
88
|
+
try {
|
|
89
|
+
const url = new URL(String(value));
|
|
90
|
+
return url.host + (url.pathname && url.pathname !== "/" ? url.pathname : "");
|
|
91
|
+
} catch {
|
|
92
|
+
return String(value || "-").replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function shouldUseTui(options = {}, stdout = process.stdout) {
|
|
97
|
+
if (options.tuiEnabled === false) return false;
|
|
98
|
+
if (options.tuiEnabled === true) return true;
|
|
99
|
+
return Boolean(stdout?.isTTY);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class RemoteTui {
|
|
103
|
+
constructor({ config, version, output = process.stdout }) {
|
|
104
|
+
this.output = output;
|
|
105
|
+
this.startedAt = Date.now();
|
|
106
|
+
this.state = {
|
|
107
|
+
account: config.user?.name || config.user?.login || "signed in",
|
|
108
|
+
agent: config.agentName || config.agentId || "-",
|
|
109
|
+
relay: normalizeRelay(config.relayUrl),
|
|
110
|
+
status: "starting",
|
|
111
|
+
reconnects: 0,
|
|
112
|
+
terminal: config.terminalEnabled !== false,
|
|
113
|
+
desktop: config.desktopEnabled === true,
|
|
114
|
+
};
|
|
115
|
+
this.version = version || "-";
|
|
116
|
+
this.transactions = [];
|
|
117
|
+
this.timer = null;
|
|
118
|
+
this.closed = false;
|
|
119
|
+
this.renderPending = false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
start() {
|
|
123
|
+
if (this.closed) return;
|
|
124
|
+
// Use the terminal's alternate screen so periodic redraws never accumulate
|
|
125
|
+
// in scrollback. Keep one column unused to avoid automatic line wrapping.
|
|
126
|
+
this.output.write(ESC + "?1049h" + ESC + "?25l" + ESC + "2J" + ESC + "H");
|
|
127
|
+
this.render();
|
|
128
|
+
this.timer = setInterval(() => this.render(), 1000);
|
|
129
|
+
this.timer.unref?.();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
stop() {
|
|
133
|
+
if (this.closed) return;
|
|
134
|
+
this.closed = true;
|
|
135
|
+
if (this.timer) clearInterval(this.timer);
|
|
136
|
+
this.timer = null;
|
|
137
|
+
this.output.write(ESC + "?25h" + ESC + "?1049l");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
handleMessage(message) {
|
|
141
|
+
if (!message || message.type !== "chat-relay-ui") return;
|
|
142
|
+
if (message.event === "connection") {
|
|
143
|
+
this.state.status = message.state || this.state.status;
|
|
144
|
+
if (Number.isFinite(Number(message.reconnects))) this.state.reconnects = Number(message.reconnects);
|
|
145
|
+
} else if (message.event === "tool:start") {
|
|
146
|
+
this.transactions.push({
|
|
147
|
+
requestId: message.requestId,
|
|
148
|
+
at: message.at || new Date().toISOString(),
|
|
149
|
+
action: message.action,
|
|
150
|
+
summary: message.summary || humanizeToolCall({ action: message.action }),
|
|
151
|
+
status: "running",
|
|
152
|
+
ok: null,
|
|
153
|
+
});
|
|
154
|
+
if (this.transactions.length > 100) this.transactions.splice(0, this.transactions.length - 100);
|
|
155
|
+
} else if (message.event === "tool:end") {
|
|
156
|
+
const item = [...this.transactions].reverse().find((entry) => entry.requestId === message.requestId);
|
|
157
|
+
if (item) {
|
|
158
|
+
item.status = "done";
|
|
159
|
+
item.ok = message.ok !== false;
|
|
160
|
+
item.durationMs = message.durationMs;
|
|
161
|
+
if (message.error && message.ok === false) item.summary += ` · ${truncate(message.error, 48)}`;
|
|
162
|
+
}
|
|
163
|
+
} else if (message.event === "system") {
|
|
164
|
+
this.transactions.push({
|
|
165
|
+
requestId: "system-" + Date.now() + "-" + Math.random(),
|
|
166
|
+
at: message.at || new Date().toISOString(),
|
|
167
|
+
summary: message.message || "Agent event",
|
|
168
|
+
status: "done",
|
|
169
|
+
ok: message.level !== "error",
|
|
170
|
+
});
|
|
171
|
+
if (this.transactions.length > 100) this.transactions.splice(0, this.transactions.length - 100);
|
|
172
|
+
}
|
|
173
|
+
this.scheduleRender();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
scheduleRender() {
|
|
177
|
+
if (this.closed || this.renderPending) return;
|
|
178
|
+
this.renderPending = true;
|
|
179
|
+
setImmediate(() => {
|
|
180
|
+
this.renderPending = false;
|
|
181
|
+
this.render();
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
render() {
|
|
186
|
+
if (this.closed) return;
|
|
187
|
+
const terminalWidth = Number(this.output.columns) || 110;
|
|
188
|
+
const width = clamp(terminalWidth - 1, MIN_FRAME_WIDTH, MAX_FRAME_WIDTH);
|
|
189
|
+
const height = Math.max(16, Number(this.output.rows) || 30);
|
|
190
|
+
const separator = "─".repeat(width);
|
|
191
|
+
const header = formatTwoColumnHeader({
|
|
192
|
+
...this.state,
|
|
193
|
+
uptimeMs: Date.now() - this.startedAt,
|
|
194
|
+
}, width);
|
|
195
|
+
// Fixed frame content occupies 11 rows. Leave one spare row at the
|
|
196
|
+
// bottom so Windows Terminal never scrolls the screen while repainting.
|
|
197
|
+
const availableRows = Math.max(1, height - FRAME_FIXED_ROWS - FRAME_BOTTOM_MARGIN);
|
|
198
|
+
const rows = this.transactions.slice(-availableRows).map((item) => formatTransactionRow(item, width));
|
|
199
|
+
while (rows.length < Math.min(availableRows, 4)) rows.unshift("");
|
|
200
|
+
|
|
201
|
+
const screen = [
|
|
202
|
+
`Chat Relay v${this.version}`,
|
|
203
|
+
separator,
|
|
204
|
+
"",
|
|
205
|
+
header,
|
|
206
|
+
"",
|
|
207
|
+
separator,
|
|
208
|
+
"Transactions",
|
|
209
|
+
"",
|
|
210
|
+
...rows,
|
|
211
|
+
].join("\n");
|
|
212
|
+
|
|
213
|
+
this.output.write(ESC + "2J" + ESC + "H" + screen);
|
|
214
|
+
}
|
|
215
|
+
}
|
package/package.json
CHANGED