@theclawlab/xgw-tui 1.0.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 ADDED
@@ -0,0 +1 @@
1
+ Terminal UI for TheClaw.
@@ -0,0 +1,5 @@
1
+ declare function formatAgentMessage(text: string): string;
2
+ declare function computeBackoffMs(attempt: number): number;
3
+ declare function formatConnectionStatus(channel: string, peer: string): string;
4
+
5
+ export { computeBackoffMs, formatAgentMessage, formatConnectionStatus };
package/dist/index.js ADDED
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+ import { WebSocket } from "ws";
6
+ import { createInterface } from "readline";
7
+
8
+ // src/helpers.ts
9
+ function formatAgentMessage(text) {
10
+ return `agent> ${text}`;
11
+ }
12
+ function computeBackoffMs(attempt) {
13
+ return Math.pow(2, attempt - 1) * 1e3;
14
+ }
15
+ function formatConnectionStatus(channel, peer) {
16
+ return `[${channel}/${peer}] Connected.`;
17
+ }
18
+ var MAX_RECONNECT_ATTEMPTS = 3;
19
+
20
+ // src/index.ts
21
+ var PING_INTERVAL_MS = 3e4;
22
+ function buildWsUrl(host, port) {
23
+ return `ws://${host}:${port}`;
24
+ }
25
+ function createClient(opts) {
26
+ let reconnectAttempt = 0;
27
+ let rl = null;
28
+ let pingTimer = null;
29
+ let ws = null;
30
+ let intentionalClose = false;
31
+ function cleanup() {
32
+ if (pingTimer) {
33
+ clearInterval(pingTimer);
34
+ pingTimer = null;
35
+ }
36
+ if (rl) {
37
+ rl.close();
38
+ rl = null;
39
+ }
40
+ }
41
+ function connect() {
42
+ const url = buildWsUrl(opts.host, opts.port);
43
+ ws = new WebSocket(url);
44
+ ws.on("open", () => {
45
+ const hello = {
46
+ type: "hello",
47
+ channel_id: opts.channel,
48
+ peer_id: opts.peer
49
+ };
50
+ ws.send(JSON.stringify(hello));
51
+ });
52
+ ws.on("message", (data) => {
53
+ let frame;
54
+ try {
55
+ frame = JSON.parse(String(data));
56
+ } catch {
57
+ return;
58
+ }
59
+ switch (frame.type) {
60
+ case "hello_ack":
61
+ onConnected();
62
+ break;
63
+ case "error":
64
+ process.stderr.write(`Error: ${frame.message}
65
+ `);
66
+ intentionalClose = true;
67
+ ws?.close();
68
+ process.exit(1);
69
+ break;
70
+ case "message":
71
+ process.stdout.write(formatAgentMessage(frame.text) + "\n");
72
+ break;
73
+ case "pong":
74
+ break;
75
+ default:
76
+ break;
77
+ }
78
+ });
79
+ ws.on("close", () => {
80
+ cleanup();
81
+ if (intentionalClose) return;
82
+ attemptReconnect();
83
+ });
84
+ ws.on("error", () => {
85
+ });
86
+ }
87
+ function onConnected() {
88
+ reconnectAttempt = 0;
89
+ process.stdout.write(formatConnectionStatus(opts.channel, opts.peer) + "\n");
90
+ pingTimer = setInterval(() => {
91
+ if (ws && ws.readyState === WebSocket.OPEN) {
92
+ ws.send(JSON.stringify({ type: "ping" }));
93
+ }
94
+ }, PING_INTERVAL_MS);
95
+ rl = createInterface({ input: process.stdin, output: process.stdout, prompt: "" });
96
+ rl.on("line", (line) => {
97
+ const trimmed = line.trim();
98
+ if (trimmed === "/quit") {
99
+ intentionalClose = true;
100
+ ws?.close();
101
+ cleanup();
102
+ process.exit(0);
103
+ }
104
+ if (ws && ws.readyState === WebSocket.OPEN && trimmed.length > 0) {
105
+ const msg = { type: "message", text: trimmed };
106
+ ws.send(JSON.stringify(msg));
107
+ }
108
+ });
109
+ rl.on("close", () => {
110
+ intentionalClose = true;
111
+ ws?.close();
112
+ cleanup();
113
+ process.exit(0);
114
+ });
115
+ }
116
+ function attemptReconnect() {
117
+ reconnectAttempt++;
118
+ if (reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
119
+ process.stderr.write("Error: Failed to reconnect after 3 attempts - Check that the xgw daemon is running\n");
120
+ process.exit(1);
121
+ }
122
+ const delayMs = computeBackoffMs(reconnectAttempt);
123
+ process.stderr.write(`Reconnecting (attempt ${reconnectAttempt}/${MAX_RECONNECT_ATTEMPTS}) in ${delayMs / 1e3}s...
124
+ `);
125
+ setTimeout(connect, delayMs);
126
+ }
127
+ process.on("SIGINT", () => {
128
+ intentionalClose = true;
129
+ ws?.close();
130
+ cleanup();
131
+ process.exit(0);
132
+ });
133
+ connect();
134
+ }
135
+ var program = new Command();
136
+ program.name("xgw-tui").description("Terminal chat client for xgw TUI plugin").requiredOption("--channel <id>", "Channel ID to connect to").requiredOption("--peer <id>", "Peer ID to identify as").option("--host <host>", "TUI plugin host", "127.0.0.1").option("--port <port>", "TUI plugin port", "18791").action((opts) => {
137
+ const port = parseInt(opts.port, 10);
138
+ if (isNaN(port) || port < 1 || port > 65535) {
139
+ process.stderr.write("Error: Invalid value for --port - Expected a number between 1 and 65535\n");
140
+ process.exit(2);
141
+ }
142
+ createClient({
143
+ channel: opts.channel,
144
+ peer: opts.peer,
145
+ host: opts.host,
146
+ port
147
+ });
148
+ });
149
+ program.parse();
150
+ export {
151
+ computeBackoffMs,
152
+ formatAgentMessage,
153
+ formatConnectionStatus
154
+ };
155
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/helpers.ts"],"sourcesContent":["import { Command } from 'commander';\r\nimport { WebSocket } from 'ws';\r\nimport { createInterface, type Interface as ReadlineInterface } from 'node:readline';\r\nimport { formatAgentMessage, computeBackoffMs, formatConnectionStatus, MAX_RECONNECT_ATTEMPTS } from './helpers.js';\r\n\r\n// Re-export helpers for backwards compatibility\r\nexport { formatAgentMessage, computeBackoffMs, formatConnectionStatus } from './helpers.js';\r\n\r\n// ── TUI Frame types ──\r\n\r\ntype TuiFrame =\r\n | { type: 'hello'; channel_id: string; peer_id: string }\r\n | { type: 'hello_ack'; channel_id: string; peer_id: string }\r\n | { type: 'error'; code: string; message: string }\r\n | { type: 'message'; text: string }\r\n | { type: 'ping' }\r\n | { type: 'pong' };\r\n\r\n// ── Core client logic ──\r\nconst PING_INTERVAL_MS = 30_000;\r\n\r\ninterface ClientOptions {\r\n channel: string;\r\n peer: string;\r\n host: string;\r\n port: number;\r\n}\r\n\r\nfunction buildWsUrl(host: string, port: number): string {\r\n return `ws://${host}:${port}`;\r\n}\r\n\r\nfunction createClient(opts: ClientOptions): void {\r\n let reconnectAttempt = 0;\r\n let rl: ReadlineInterface | null = null;\r\n let pingTimer: ReturnType<typeof setInterval> | null = null;\r\n let ws: WebSocket | null = null;\r\n let intentionalClose = false;\r\n\r\n function cleanup(): void {\r\n if (pingTimer) {\r\n clearInterval(pingTimer);\r\n pingTimer = null;\r\n }\r\n if (rl) {\r\n rl.close();\r\n rl = null;\r\n }\r\n }\r\n\r\n function connect(): void {\r\n const url = buildWsUrl(opts.host, opts.port);\r\n ws = new WebSocket(url);\r\n\r\n ws.on('open', () => {\r\n const hello: TuiFrame = {\r\n type: 'hello',\r\n channel_id: opts.channel,\r\n peer_id: opts.peer,\r\n };\r\n ws!.send(JSON.stringify(hello));\r\n });\r\n\r\n ws.on('message', (data) => {\r\n let frame: TuiFrame;\r\n try {\r\n frame = JSON.parse(String(data)) as TuiFrame;\r\n } catch {\r\n return;\r\n }\r\n\r\n switch (frame.type) {\r\n case 'hello_ack':\r\n onConnected();\r\n break;\r\n case 'error':\r\n process.stderr.write(`Error: ${frame.message}\\n`);\r\n intentionalClose = true;\r\n ws?.close();\r\n process.exit(1);\r\n break;\r\n case 'message':\r\n process.stdout.write(formatAgentMessage(frame.text) + '\\n');\r\n break;\r\n case 'pong':\r\n // keepalive ack, nothing to do\r\n break;\r\n default:\r\n break;\r\n }\r\n });\r\n\r\n ws.on('close', () => {\r\n cleanup();\r\n if (intentionalClose) return;\r\n attemptReconnect();\r\n });\r\n\r\n ws.on('error', () => {\r\n // 'close' event will fire after this, reconnect handled there\r\n });\r\n }\r\n\r\n function onConnected(): void {\r\n reconnectAttempt = 0;\r\n process.stdout.write(formatConnectionStatus(opts.channel, opts.peer) + '\\n');\r\n\r\n // Start ping keepalive\r\n pingTimer = setInterval(() => {\r\n if (ws && ws.readyState === WebSocket.OPEN) {\r\n ws.send(JSON.stringify({ type: 'ping' } satisfies TuiFrame));\r\n }\r\n }, PING_INTERVAL_MS);\r\n\r\n // Start readline loop\r\n rl = createInterface({ input: process.stdin, output: process.stdout, prompt: '' });\r\n\r\n rl.on('line', (line) => {\r\n const trimmed = line.trim();\r\n if (trimmed === '/quit') {\r\n intentionalClose = true;\r\n ws?.close();\r\n cleanup();\r\n process.exit(0);\r\n }\r\n if (ws && ws.readyState === WebSocket.OPEN && trimmed.length > 0) {\r\n const msg: TuiFrame = { type: 'message', text: trimmed };\r\n ws.send(JSON.stringify(msg));\r\n }\r\n });\r\n\r\n rl.on('close', () => {\r\n // Ctrl+C or EOF\r\n intentionalClose = true;\r\n ws?.close();\r\n cleanup();\r\n process.exit(0);\r\n });\r\n }\r\n\r\n function attemptReconnect(): void {\r\n reconnectAttempt++;\r\n if (reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {\r\n process.stderr.write('Error: Failed to reconnect after 3 attempts - Check that the xgw daemon is running\\n');\r\n process.exit(1);\r\n }\r\n const delayMs = computeBackoffMs(reconnectAttempt);\r\n process.stderr.write(`Reconnecting (attempt ${reconnectAttempt}/${MAX_RECONNECT_ATTEMPTS}) in ${delayMs / 1000}s...\\n`);\r\n setTimeout(connect, delayMs);\r\n }\r\n\r\n // Handle Ctrl+C globally\r\n process.on('SIGINT', () => {\r\n intentionalClose = true;\r\n ws?.close();\r\n cleanup();\r\n process.exit(0);\r\n });\r\n\r\n connect();\r\n}\r\n\r\n// ── CLI ──\r\n\r\nconst program = new Command();\r\n\r\nprogram\r\n .name('xgw-tui')\r\n .description('Terminal chat client for xgw TUI plugin')\r\n .requiredOption('--channel <id>', 'Channel ID to connect to')\r\n .requiredOption('--peer <id>', 'Peer ID to identify as')\r\n .option('--host <host>', 'TUI plugin host', '127.0.0.1')\r\n .option('--port <port>', 'TUI plugin port', '18791')\r\n .action((opts: { channel: string; peer: string; host: string; port: string }) => {\r\n const port = parseInt(opts.port, 10);\r\n if (isNaN(port) || port < 1 || port > 65535) {\r\n process.stderr.write('Error: Invalid value for --port - Expected a number between 1 and 65535\\n');\r\n process.exit(2);\r\n }\r\n createClient({\r\n channel: opts.channel,\r\n peer: opts.peer,\r\n host: opts.host,\r\n port,\r\n });\r\n });\r\n\r\nprogram.parse();\r\n","// ── Helpers (extracted for testability) ──\r\n\r\nexport function formatAgentMessage(text: string): string {\r\n return `agent> ${text}`;\r\n}\r\n\r\nexport function computeBackoffMs(attempt: number): number {\r\n return Math.pow(2, attempt - 1) * 1000;\r\n}\r\n\r\nexport function formatConnectionStatus(channel: string, peer: string): string {\r\n return `[${channel}/${peer}] Connected.`;\r\n}\r\n\r\nexport const MAX_RECONNECT_ATTEMPTS = 3;\r\n"],"mappings":";;;AAAA,SAAS,eAAe;AACxB,SAAS,iBAAiB;AAC1B,SAAS,uBAA4D;;;ACA9D,SAAS,mBAAmB,MAAsB;AACvD,SAAO,UAAU,IAAI;AACvB;AAEO,SAAS,iBAAiB,SAAyB;AACxD,SAAO,KAAK,IAAI,GAAG,UAAU,CAAC,IAAI;AACpC;AAEO,SAAS,uBAAuB,SAAiB,MAAsB;AAC5E,SAAO,IAAI,OAAO,IAAI,IAAI;AAC5B;AAEO,IAAM,yBAAyB;;;ADKtC,IAAM,mBAAmB;AASzB,SAAS,WAAW,MAAc,MAAsB;AACtD,SAAO,QAAQ,IAAI,IAAI,IAAI;AAC7B;AAEA,SAAS,aAAa,MAA2B;AAC/C,MAAI,mBAAmB;AACvB,MAAI,KAA+B;AACnC,MAAI,YAAmD;AACvD,MAAI,KAAuB;AAC3B,MAAI,mBAAmB;AAEvB,WAAS,UAAgB;AACvB,QAAI,WAAW;AACb,oBAAc,SAAS;AACvB,kBAAY;AAAA,IACd;AACA,QAAI,IAAI;AACN,SAAG,MAAM;AACT,WAAK;AAAA,IACP;AAAA,EACF;AAEA,WAAS,UAAgB;AACvB,UAAM,MAAM,WAAW,KAAK,MAAM,KAAK,IAAI;AAC3C,SAAK,IAAI,UAAU,GAAG;AAEtB,OAAG,GAAG,QAAQ,MAAM;AAClB,YAAM,QAAkB;AAAA,QACtB,MAAM;AAAA,QACN,YAAY,KAAK;AAAA,QACjB,SAAS,KAAK;AAAA,MAChB;AACA,SAAI,KAAK,KAAK,UAAU,KAAK,CAAC;AAAA,IAChC,CAAC;AAED,OAAG,GAAG,WAAW,CAAC,SAAS;AACzB,UAAI;AACJ,UAAI;AACF,gBAAQ,KAAK,MAAM,OAAO,IAAI,CAAC;AAAA,MACjC,QAAQ;AACN;AAAA,MACF;AAEA,cAAQ,MAAM,MAAM;AAAA,QAClB,KAAK;AACH,sBAAY;AACZ;AAAA,QACF,KAAK;AACH,kBAAQ,OAAO,MAAM,UAAU,MAAM,OAAO;AAAA,CAAI;AAChD,6BAAmB;AACnB,cAAI,MAAM;AACV,kBAAQ,KAAK,CAAC;AACd;AAAA,QACF,KAAK;AACH,kBAAQ,OAAO,MAAM,mBAAmB,MAAM,IAAI,IAAI,IAAI;AAC1D;AAAA,QACF,KAAK;AAEH;AAAA,QACF;AACE;AAAA,MACJ;AAAA,IACF,CAAC;AAED,OAAG,GAAG,SAAS,MAAM;AACnB,cAAQ;AACR,UAAI,iBAAkB;AACtB,uBAAiB;AAAA,IACnB,CAAC;AAED,OAAG,GAAG,SAAS,MAAM;AAAA,IAErB,CAAC;AAAA,EACH;AAEA,WAAS,cAAoB;AAC3B,uBAAmB;AACnB,YAAQ,OAAO,MAAM,uBAAuB,KAAK,SAAS,KAAK,IAAI,IAAI,IAAI;AAG3E,gBAAY,YAAY,MAAM;AAC5B,UAAI,MAAM,GAAG,eAAe,UAAU,MAAM;AAC1C,WAAG,KAAK,KAAK,UAAU,EAAE,MAAM,OAAO,CAAoB,CAAC;AAAA,MAC7D;AAAA,IACF,GAAG,gBAAgB;AAGnB,SAAK,gBAAgB,EAAE,OAAO,QAAQ,OAAO,QAAQ,QAAQ,QAAQ,QAAQ,GAAG,CAAC;AAEjF,OAAG,GAAG,QAAQ,CAAC,SAAS;AACtB,YAAM,UAAU,KAAK,KAAK;AAC1B,UAAI,YAAY,SAAS;AACvB,2BAAmB;AACnB,YAAI,MAAM;AACV,gBAAQ;AACR,gBAAQ,KAAK,CAAC;AAAA,MAChB;AACA,UAAI,MAAM,GAAG,eAAe,UAAU,QAAQ,QAAQ,SAAS,GAAG;AAChE,cAAM,MAAgB,EAAE,MAAM,WAAW,MAAM,QAAQ;AACvD,WAAG,KAAK,KAAK,UAAU,GAAG,CAAC;AAAA,MAC7B;AAAA,IACF,CAAC;AAED,OAAG,GAAG,SAAS,MAAM;AAEnB,yBAAmB;AACnB,UAAI,MAAM;AACV,cAAQ;AACR,cAAQ,KAAK,CAAC;AAAA,IAChB,CAAC;AAAA,EACH;AAEA,WAAS,mBAAyB;AAChC;AACA,QAAI,mBAAmB,wBAAwB;AAC7C,cAAQ,OAAO,MAAM,sFAAsF;AAC3G,cAAQ,KAAK,CAAC;AAAA,IAChB;AACA,UAAM,UAAU,iBAAiB,gBAAgB;AACjD,YAAQ,OAAO,MAAM,yBAAyB,gBAAgB,IAAI,sBAAsB,QAAQ,UAAU,GAAI;AAAA,CAAQ;AACtH,eAAW,SAAS,OAAO;AAAA,EAC7B;AAGA,UAAQ,GAAG,UAAU,MAAM;AACzB,uBAAmB;AACnB,QAAI,MAAM;AACV,YAAQ;AACR,YAAQ,KAAK,CAAC;AAAA,EAChB,CAAC;AAED,UAAQ;AACV;AAIA,IAAM,UAAU,IAAI,QAAQ;AAE5B,QACG,KAAK,SAAS,EACd,YAAY,yCAAyC,EACrD,eAAe,kBAAkB,0BAA0B,EAC3D,eAAe,eAAe,wBAAwB,EACtD,OAAO,iBAAiB,mBAAmB,WAAW,EACtD,OAAO,iBAAiB,mBAAmB,OAAO,EAClD,OAAO,CAAC,SAAwE;AAC/E,QAAM,OAAO,SAAS,KAAK,MAAM,EAAE;AACnC,MAAI,MAAM,IAAI,KAAK,OAAO,KAAK,OAAO,OAAO;AAC3C,YAAQ,OAAO,MAAM,2EAA2E;AAChG,YAAQ,KAAK,CAAC;AAAA,EAChB;AACA,eAAa;AAAA,IACX,SAAS,KAAK;AAAA,IACd,MAAM,KAAK;AAAA,IACX,MAAM,KAAK;AAAA,IACX;AAAA,EACF,CAAC;AACH,CAAC;AAEH,QAAQ,MAAM;","names":[]}
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@theclawlab/xgw-tui",
3
+ "type": "module",
4
+ "version": "1.0.3",
5
+ "description": "Terminal chat client for TheClaw",
6
+ "bin": {
7
+ "xgw-tui": "dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsup",
11
+ "dev": "tsup --watch",
12
+ "release:local": "npm run build && npm link",
13
+ "release:public": "npm run build && npm publish --access public",
14
+ "release:public:skip-test": "npm run build && npm publish --access public"
15
+ },
16
+ "license": "MIT",
17
+ "publishConfig": {
18
+ "access": "public",
19
+ "registry": "https://registry.npmjs.org"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "README.md"
24
+ ],
25
+ "devDependencies": {
26
+ "@types/node": "^25.4.0",
27
+ "@types/ws": "^8.18.0",
28
+ "tsup": "^8.5.1",
29
+ "typescript": "^5.9.3"
30
+ },
31
+ "dependencies": {
32
+ "commander": "^14.0.3",
33
+ "ws": "^8.18.0"
34
+ }
35
+ }