httpcat-cli 0.0.9 → 0.0.10

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.
@@ -3,16 +3,58 @@ import WebSocket from "ws";
3
3
  import chalk from "chalk";
4
4
  // @ts-ignore - neo-blessed doesn't have types, but @types/blessed provides compatible types
5
5
  import blessed from "neo-blessed";
6
- import { formatAddress } from "../utils/formatting.js";
6
+ import { formatAddress, formatTokenAmount, formatCurrency } from "../utils/formatting.js";
7
7
  import { handleError } from "../utils/errors.js";
8
8
  import { config } from "../config.js";
9
9
  import { printCat } from "../interactive/art.js";
10
10
  import { resolveTokenId } from "../utils/token-resolver.js";
11
11
  import { privateKeyToAccount } from "viem/accounts";
12
+ import { buyToken } from "./buy.js";
13
+ import { sellToken, parseTokenAmount } from "./sell.js";
14
+ import { getTokenInfo } from "./info.js";
12
15
  const CHAT_JOIN_ENTRYPOINT = "chat_join";
13
16
  const CHAT_MESSAGE_ENTRYPOINT = "chat_message";
14
17
  const CHAT_RENEW_LEASE_ENTRYPOINT = "chat_renew_lease";
15
18
  const LEASE_DURATION_MS = 10 * 60 * 1000; // 10 minutes
19
+ /**
20
+ * Normalize WebSocket URL based on agent URL
21
+ * - Converts protocol (ws:// <-> wss://) to match agent URL
22
+ * - Replaces hostname with agent URL's hostname (handles localhost URLs from backend)
23
+ * - Preserves path and query parameters from WebSocket URL
24
+ */
25
+ function normalizeWebSocketUrl(wsUrl, agentUrl) {
26
+ try {
27
+ const agentUrlObj = new URL(agentUrl);
28
+ const wsUrlObj = new URL(wsUrl);
29
+ // Determine correct protocol based on agent URL
30
+ const shouldUseWss = agentUrlObj.protocol === "https:";
31
+ const shouldUseWs = agentUrlObj.protocol === "http:";
32
+ // Replace hostname with agent URL's hostname
33
+ // This handles cases where backend returns localhost URLs
34
+ wsUrlObj.hostname = agentUrlObj.hostname;
35
+ // Use agent URL's port if it's explicitly set, otherwise use default for protocol
36
+ if (agentUrlObj.port) {
37
+ wsUrlObj.port = agentUrlObj.port;
38
+ }
39
+ else {
40
+ // Remove port to use default (80 for ws, 443 for wss)
41
+ wsUrlObj.port = "";
42
+ }
43
+ // Convert protocol to match agent URL
44
+ if (shouldUseWss) {
45
+ wsUrlObj.protocol = "wss:";
46
+ }
47
+ else if (shouldUseWs) {
48
+ wsUrlObj.protocol = "ws:";
49
+ }
50
+ return wsUrlObj.toString();
51
+ }
52
+ catch (error) {
53
+ // If URL parsing fails, return original
54
+ // This is safer than trying to guess
55
+ return wsUrl;
56
+ }
57
+ }
16
58
  export async function joinChat(client, tokenIdentifier, silent = false) {
17
59
  let tokenId;
18
60
  // If token identifier provided, resolve it to tokenId
@@ -128,14 +170,42 @@ function formatTimeRemaining(expiresAt) {
128
170
  }
129
171
  return `${seconds}s`;
130
172
  }
173
+ /**
174
+ * Format buy result compactly for chat log
175
+ */
176
+ function formatBuyResultCompact(result) {
177
+ const graduationStatus = result.graduationReached
178
+ ? "✅ GRADUATED!"
179
+ : `${result.graduationProgress.toFixed(2)}%`;
180
+ return chalk.green("✅ Buy successful! ") +
181
+ `Received ${chalk.cyan(formatTokenAmount(result.tokensReceived))} tokens, ` +
182
+ `spent ${chalk.yellow(formatCurrency(result.amountSpent))}, ` +
183
+ `new price ${chalk.cyan(formatCurrency(result.newPrice))}, ` +
184
+ `graduation ${chalk.magenta(graduationStatus)}`;
185
+ }
186
+ /**
187
+ * Format sell result compactly for chat log
188
+ */
189
+ function formatSellResultCompact(result) {
190
+ return chalk.green("✅ Sell successful! ") +
191
+ `Sold ${chalk.cyan(formatTokenAmount(result.tokensSold))} tokens, ` +
192
+ `received ${chalk.yellow(formatCurrency(result.usdcReceived))}, ` +
193
+ `new price ${chalk.cyan(formatCurrency(result.newPrice))}, ` +
194
+ `graduation ${chalk.magenta(result.graduationProgress.toFixed(2) + "%")}`;
195
+ }
131
196
  function updateHeaderBox(headerBox, leaseInfo, tokenName, isConnected = false) {
132
197
  const title = tokenName
133
198
  ? `💬 httpcat Chat: ${tokenName}`
134
199
  : "💬 httpcat Chat Stream";
135
200
  // Get terminal width, default to 80 if not available
136
- const width = process.stdout.columns || 80;
137
- const separator = "═".repeat(Math.max(20, width - 2));
138
- const lineSeparator = "─".repeat(Math.max(20, width - 2));
201
+ // Account for padding (left + right = 2)
202
+ const terminalWidth = process.stdout.columns || 80;
203
+ const boxWidth = headerBox.width;
204
+ const availableWidth = typeof boxWidth === "number" ? boxWidth - 2 : terminalWidth - 2;
205
+ const separatorWidth = Math.max(20, Math.min(availableWidth, terminalWidth - 2));
206
+ // Use safe separator that won't overflow
207
+ const separator = "═".repeat(separatorWidth);
208
+ const lineSeparator = "─".repeat(separatorWidth);
139
209
  // Build content line by line - use plain text (blessed will handle wrapping)
140
210
  const lines = [];
141
211
  lines.push(title);
@@ -157,8 +227,13 @@ function updateHeaderBox(headerBox, leaseInfo, tokenName, isConnected = false) {
157
227
  lines.push("💡 Type your message and press Enter to send");
158
228
  lines.push("💡 Type /exit or Ctrl+C to quit");
159
229
  lines.push("💡 Type /renew to renew your lease");
230
+ if (tokenName) {
231
+ lines.push("💡 Type /buy <amount> to buy tokens");
232
+ lines.push("💡 Type /sell <amount> to sell tokens");
233
+ }
160
234
  lines.push("");
161
235
  lines.push(lineSeparator);
236
+ // Clear and set content to prevent overlapping
162
237
  headerBox.setContent(lines.join("\n"));
163
238
  }
164
239
  async function ensureLeaseValid(client, leaseInfo, messageLogBox, screen) {
@@ -244,9 +319,16 @@ export async function startChatStream(client, jsonMode = false, tokenIdentifier)
244
319
  if (!leaseInfo || leaseInfo.leaseExpiresAt.getTime() <= Date.now()) {
245
320
  throw new Error("Lease is invalid or expired. Please try again.");
246
321
  }
247
- // Small delay to ensure database transaction commits before WebSocket connection
248
- await new Promise((resolve) => setTimeout(resolve, 500));
249
- const wsUrlObj = new URL(joinResult.wsUrl);
322
+ // Adjust delay based on whether agent is remote or local
323
+ // Remote servers may need more time for database replication/consistency
324
+ const agentUrl = client.getAgentUrl();
325
+ const isLocalhost = agentUrl.includes("localhost") || agentUrl.includes("127.0.0.1");
326
+ const delay = isLocalhost ? 500 : 1500; // 500ms for local, 1.5s for remote
327
+ await new Promise((resolve) => setTimeout(resolve, delay));
328
+ // Normalize WebSocket URL protocol (ws:// vs wss://) based on agent URL
329
+ // Replaces hostname with agent URL's hostname and converts protocol
330
+ const normalizedWsUrl = normalizeWebSocketUrl(joinResult.wsUrl, agentUrl);
331
+ const wsUrlObj = new URL(normalizedWsUrl);
250
332
  wsUrlObj.searchParams.set("leaseId", joinResult.leaseId);
251
333
  wsUrl = wsUrlObj.toString();
252
334
  // Helper function to attach websocket handlers
@@ -317,8 +399,9 @@ export async function startChatStream(client, jsonMode = false, tokenIdentifier)
317
399
  process.stdout.write("\r\x1b[K");
318
400
  }
319
401
  // Display the message (only if not already displayed)
402
+ // In JSON mode, displayMessage should never be called, but add explicit check
320
403
  if (!displayedMessageIds.has(msg.messageId)) {
321
- displayMessage(msg, isOwn, false, messageLogBox || undefined, rl || undefined);
404
+ displayMessage(msg, isOwn, false, messageLogBox || undefined, jsonMode ? undefined : (rl || undefined));
322
405
  }
323
406
  // Re-enable input - clear the input line completely
324
407
  isSending = false;
@@ -338,8 +421,9 @@ export async function startChatStream(client, jsonMode = false, tokenIdentifier)
338
421
  }
339
422
  else {
340
423
  // Not our message, just display it normally
424
+ // In JSON mode, displayMessage should never be called, but add explicit check
341
425
  if (!displayedMessageIds.has(msg.messageId)) {
342
- displayMessage(msg, isOwn, false, messageLogBox || undefined, rl || undefined);
426
+ displayMessage(msg, isOwn, false, messageLogBox || undefined, jsonMode ? undefined : (rl || undefined));
343
427
  }
344
428
  }
345
429
  }
@@ -596,112 +680,40 @@ export async function startChatStream(client, jsonMode = false, tokenIdentifier)
596
680
  if (!connected) {
597
681
  throw new Error("Failed to establish WebSocket connection");
598
682
  }
683
+ // Check lease expiration every 30 seconds (works for both JSON and interactive modes)
684
+ leaseCheckInterval = setInterval(() => {
685
+ if (leaseInfo && leaseInfo.leaseExpiresAt.getTime() <= Date.now()) {
686
+ if (jsonMode) {
687
+ console.log(JSON.stringify({
688
+ type: 'lease_expired',
689
+ message: 'Your lease has expired. Use /renew to continue chatting.',
690
+ }));
691
+ }
692
+ else if (messageLogBox) {
693
+ messageLogBox.log(chalk.yellow("⏱️ Your lease has expired. Type /renew to continue chatting."));
694
+ messageLogBox.setScrollPerc(100);
695
+ screen?.render();
696
+ }
697
+ else if (rl) {
698
+ console.log(chalk.yellow("⏱️ Your lease has expired. Type /renew to continue chatting."));
699
+ }
700
+ leaseInfo = null;
701
+ if (headerBox) {
702
+ updateHeaderBox(headerBox, null, tokenIdentifier, true);
703
+ screen?.render();
704
+ }
705
+ }
706
+ }, 30000);
599
707
  // Set up blessed UI or readline interface (only in interactive mode)
600
708
  if (!jsonMode) {
601
709
  let useBlessed = true;
602
- // Try to create blessed screen with error handling
603
- try {
604
- // Check if terminal supports blessed (basic compatibility check)
605
- if (!process.stdout.isTTY) {
606
- throw new Error("Not a TTY - falling back to readline");
607
- }
608
- // Create blessed screen and widgets
609
- screen = blessed.screen({
610
- smartCSR: true,
611
- title: "httpcat Chat",
612
- fullUnicode: true,
613
- // Add error handling for terminal compatibility
614
- fastCSR: false, // Disable fast CSR to avoid rendering issues
615
- });
616
- // Calculate header height (approximately 10 lines)
617
- const headerHeight = 10;
618
- const inputHeight = 3;
619
- // Create header box (fixed at top)
620
- headerBox = blessed.box({
621
- top: 0,
622
- left: 0,
623
- width: "100%",
624
- height: headerHeight,
625
- content: "",
626
- tags: false, // Disable tags to avoid rendering issues
627
- wrap: true,
628
- scrollable: false,
629
- alwaysScroll: false,
630
- padding: {
631
- left: 1,
632
- right: 1,
633
- },
634
- style: {
635
- fg: "white",
636
- bg: "black",
637
- },
638
- });
639
- // Create message log box (scrollable, middle section)
640
- messageLogBox = blessed.log({
641
- top: headerHeight,
642
- left: 0,
643
- width: "100%",
644
- height: `100%-${headerHeight + inputHeight}`,
645
- tags: true,
646
- scrollable: true,
647
- alwaysScroll: true,
648
- scrollbar: {
649
- ch: " ",
650
- inverse: true,
651
- },
652
- style: {
653
- fg: "white",
654
- bg: "black",
655
- },
656
- });
657
- // Create input box (fixed at bottom)
658
- inputBox = blessed.textbox({
659
- bottom: 0,
660
- left: 0,
661
- width: "100%",
662
- height: inputHeight,
663
- content: "",
664
- inputOnFocus: true,
665
- tags: true,
666
- keys: true,
667
- style: {
668
- fg: "cyan",
669
- bg: "black",
670
- focus: {
671
- fg: "white",
672
- bg: "blue",
673
- },
674
- },
675
- });
676
- // Append widgets to screen
677
- screen.append(headerBox);
678
- screen.append(messageLogBox);
679
- screen.append(inputBox);
680
- // Test render to catch early errors
681
- screen.render();
682
- // Initial header update
683
- updateHeaderBox(headerBox, leaseInfo, tokenIdentifier, false);
684
- screen.render();
685
- }
686
- catch (blessedError) {
687
- // Blessed failed - fallback to readline
710
+ // Check if terminal supports blessed (basic compatibility check)
711
+ // If not a TTY, silently fall back to readline
712
+ if (!process.stdout.isTTY) {
688
713
  useBlessed = false;
689
- console.error(chalk.yellow("⚠️ Blessed UI initialization failed, falling back to readline interface"));
690
- console.error(chalk.dim(` Error: ${blessedError instanceof Error ? blessedError.message : String(blessedError)}`));
691
- // Clean up any partial blessed setup
692
- if (screen) {
693
- try {
694
- screen.destroy();
695
- }
696
- catch {
697
- // Ignore cleanup errors
698
- }
699
- screen = null;
700
- headerBox = null;
701
- messageLogBox = null;
702
- inputBox = null;
703
- }
704
- // Create readline interface as fallback
714
+ }
715
+ // Helper function to set up readline interface
716
+ const setupReadline = () => {
705
717
  rl = createInterface({
706
718
  input: process.stdin,
707
719
  output: process.stdout,
@@ -725,8 +737,123 @@ export async function startChatStream(client, jsonMode = false, tokenIdentifier)
725
737
  console.log(chalk.dim("💡 Type your message and press Enter to send"));
726
738
  console.log(chalk.dim("💡 Type /exit or Ctrl+C to quit"));
727
739
  console.log(chalk.dim("💡 Type /renew to renew your lease"));
740
+ if (tokenIdentifier) {
741
+ console.log(chalk.dim("💡 Type /buy <amount> to buy tokens"));
742
+ console.log(chalk.dim("💡 Type /sell <amount> to sell tokens"));
743
+ }
728
744
  console.log(chalk.cyan("─".repeat(80)));
729
745
  console.log();
746
+ };
747
+ // Try to create blessed screen with error handling
748
+ if (useBlessed) {
749
+ try {
750
+ // Create blessed screen and widgets
751
+ screen = blessed.screen({
752
+ smartCSR: true,
753
+ title: "httpcat Chat",
754
+ fullUnicode: true,
755
+ // Add error handling for terminal compatibility
756
+ fastCSR: false, // Disable fast CSR to avoid rendering issues
757
+ });
758
+ // Calculate header height (approximately 10 lines)
759
+ const headerHeight = 10;
760
+ const inputHeight = 3;
761
+ // Create header box (fixed at top)
762
+ headerBox = blessed.box({
763
+ top: 0,
764
+ left: 0,
765
+ width: "100%",
766
+ height: headerHeight,
767
+ content: "",
768
+ tags: false, // Disable tags to avoid rendering issues
769
+ wrap: true,
770
+ scrollable: false,
771
+ alwaysScroll: false,
772
+ padding: {
773
+ left: 1,
774
+ right: 1,
775
+ top: 0,
776
+ bottom: 0,
777
+ },
778
+ style: {
779
+ fg: "white",
780
+ bg: "black",
781
+ },
782
+ });
783
+ // Create message log box (scrollable, middle section)
784
+ messageLogBox = blessed.log({
785
+ top: headerHeight,
786
+ left: 0,
787
+ width: "100%",
788
+ height: `100%-${headerHeight + inputHeight}`,
789
+ tags: true,
790
+ scrollable: true,
791
+ alwaysScroll: true,
792
+ scrollbar: {
793
+ ch: " ",
794
+ inverse: true,
795
+ },
796
+ style: {
797
+ fg: "white",
798
+ bg: "black",
799
+ },
800
+ });
801
+ // Create input box (fixed at bottom)
802
+ inputBox = blessed.textbox({
803
+ bottom: 0,
804
+ left: 0,
805
+ width: "100%",
806
+ height: inputHeight,
807
+ content: "",
808
+ inputOnFocus: true,
809
+ tags: true,
810
+ keys: true,
811
+ style: {
812
+ fg: "cyan",
813
+ bg: "black",
814
+ focus: {
815
+ fg: "white",
816
+ bg: "blue",
817
+ },
818
+ },
819
+ });
820
+ // Append widgets to screen
821
+ screen.append(headerBox);
822
+ screen.append(messageLogBox);
823
+ screen.append(inputBox);
824
+ // Test render to catch early errors
825
+ screen.render();
826
+ // Initial header update
827
+ updateHeaderBox(headerBox, leaseInfo, tokenIdentifier, false);
828
+ screen.render();
829
+ }
830
+ catch (blessedError) {
831
+ // Blessed failed - fallback to readline
832
+ useBlessed = false;
833
+ // Only show error if it's not a TTY issue (which we already handled silently)
834
+ if (process.stdout.isTTY) {
835
+ console.error(chalk.yellow("⚠️ Blessed UI initialization failed, falling back to readline interface"));
836
+ console.error(chalk.dim(` Error: ${blessedError instanceof Error ? blessedError.message : String(blessedError)}`));
837
+ }
838
+ // Clean up any partial blessed setup
839
+ if (screen) {
840
+ try {
841
+ screen.destroy();
842
+ }
843
+ catch {
844
+ // Ignore cleanup errors
845
+ }
846
+ screen = null;
847
+ headerBox = null;
848
+ messageLogBox = null;
849
+ inputBox = null;
850
+ }
851
+ setupReadline();
852
+ }
853
+ }
854
+ else {
855
+ // Not a TTY or blessed disabled - use readline directly
856
+ setupReadline();
730
857
  }
731
858
  // Display last messages
732
859
  if (joinResult.lastMessages.length > 0) {
@@ -738,7 +865,8 @@ export async function startChatStream(client, jsonMode = false, tokenIdentifier)
738
865
  sortedMessages.forEach((msg) => {
739
866
  displayedMessageIds.add(msg.messageId);
740
867
  const isOwn = msg.author === userAddress;
741
- displayMessage(msg, isOwn, false, messageLogBox || undefined, rl || undefined);
868
+ // This is inside !jsonMode block, but add explicit check for safety
869
+ displayMessage(msg, isOwn, false, messageLogBox || undefined, jsonMode ? undefined : (rl || undefined));
742
870
  });
743
871
  }
744
872
  // Only set up blessed-specific handlers if using blessed
@@ -749,30 +877,12 @@ export async function startChatStream(client, jsonMode = false, tokenIdentifier)
749
877
  });
750
878
  // Update header with lease countdown every second
751
879
  headerUpdateInterval = setInterval(() => {
752
- if (leaseInfo && headerBox) {
880
+ if (leaseInfo && headerBox && screen && !isExiting) {
753
881
  updateHeaderBox(headerBox, leaseInfo, tokenIdentifier, true);
754
- screen?.render();
882
+ screen.render();
755
883
  }
756
884
  }, 1000);
757
885
  }
758
- // Check lease expiration every 30 seconds (works for both blessed and readline)
759
- leaseCheckInterval = setInterval(() => {
760
- if (leaseInfo && leaseInfo.leaseExpiresAt.getTime() <= Date.now()) {
761
- if (messageLogBox) {
762
- messageLogBox.log(chalk.yellow("⏱️ Your lease has expired. Type /renew to continue chatting."));
763
- messageLogBox.setScrollPerc(100);
764
- screen?.render();
765
- }
766
- else if (rl) {
767
- console.log(chalk.yellow("⏱️ Your lease has expired. Type /renew to continue chatting."));
768
- }
769
- leaseInfo = null;
770
- if (headerBox) {
771
- updateHeaderBox(headerBox, null, tokenIdentifier, true);
772
- screen?.render();
773
- }
774
- }
775
- }, 30000);
776
886
  // Handle input submission
777
887
  if (useBlessed && inputBox) {
778
888
  // Blessed input handling
@@ -841,7 +951,10 @@ export async function startChatStream(client, jsonMode = false, tokenIdentifier)
841
951
  await new Promise((resolve) => setTimeout(resolve, 2000));
842
952
  // Reconnect with new lease - with retry logic
843
953
  if (wsUrl) {
844
- const wsUrlObj = new URL(wsUrl);
954
+ // Normalize WebSocket URL protocol based on agent URL
955
+ const agentUrl = client.getAgentUrl();
956
+ const normalizedWsUrl = normalizeWebSocketUrl(wsUrl, agentUrl);
957
+ const wsUrlObj = new URL(normalizedWsUrl);
845
958
  wsUrlObj.searchParams.set("leaseId", leaseInfo.leaseId);
846
959
  wsUrl = wsUrlObj.toString(); // Update stored URL
847
960
  // Retry logic with exponential backoff
@@ -952,9 +1065,130 @@ export async function startChatStream(client, jsonMode = false, tokenIdentifier)
952
1065
  }
953
1066
  return;
954
1067
  }
1068
+ case "/buy": {
1069
+ // Only allow in token-specific chats
1070
+ if (!tokenIdentifier) {
1071
+ if (messageLogBox) {
1072
+ messageLogBox.log(chalk.yellow("⚠️ /buy command only works in token-specific chats"));
1073
+ messageLogBox.setScrollPerc(100);
1074
+ screen?.render();
1075
+ }
1076
+ inputBox?.clearValue();
1077
+ inputBox?.focus();
1078
+ screen?.render();
1079
+ return;
1080
+ }
1081
+ try {
1082
+ const parts = trimmed.split(" ");
1083
+ if (parts.length < 2) {
1084
+ if (messageLogBox) {
1085
+ messageLogBox.log(chalk.yellow("⚠️ Usage: /buy <amount> (e.g., /buy 0.05)"));
1086
+ messageLogBox.setScrollPerc(100);
1087
+ screen?.render();
1088
+ }
1089
+ inputBox?.clearValue();
1090
+ inputBox?.focus();
1091
+ screen?.render();
1092
+ return;
1093
+ }
1094
+ const amount = parts[1];
1095
+ inputBox?.setValue(`💰 Buying tokens...`);
1096
+ screen?.render();
1097
+ const isTestMode = client.getNetwork().includes("sepolia");
1098
+ const result = await buyToken(client, tokenIdentifier, amount, isTestMode, true // silent mode
1099
+ );
1100
+ if (messageLogBox) {
1101
+ messageLogBox.log(formatBuyResultCompact(result));
1102
+ messageLogBox.setScrollPerc(100);
1103
+ screen?.render();
1104
+ }
1105
+ inputBox?.clearValue();
1106
+ inputBox?.focus();
1107
+ screen?.render();
1108
+ }
1109
+ catch (error) {
1110
+ const errorMsg = error instanceof Error ? error.message : String(error);
1111
+ if (messageLogBox) {
1112
+ messageLogBox.log(chalk.red(`❌ Buy failed: ${errorMsg}`));
1113
+ messageLogBox.setScrollPerc(100);
1114
+ screen?.render();
1115
+ }
1116
+ inputBox?.clearValue();
1117
+ inputBox?.focus();
1118
+ screen?.render();
1119
+ }
1120
+ return;
1121
+ }
1122
+ case "/sell": {
1123
+ // Only allow in token-specific chats
1124
+ if (!tokenIdentifier) {
1125
+ if (messageLogBox) {
1126
+ messageLogBox.log(chalk.yellow("⚠️ /sell command only works in token-specific chats"));
1127
+ messageLogBox.setScrollPerc(100);
1128
+ screen?.render();
1129
+ }
1130
+ inputBox?.clearValue();
1131
+ inputBox?.focus();
1132
+ screen?.render();
1133
+ return;
1134
+ }
1135
+ try {
1136
+ const parts = trimmed.split(" ");
1137
+ if (parts.length < 2) {
1138
+ if (messageLogBox) {
1139
+ messageLogBox.log(chalk.yellow("⚠️ Usage: /sell <amount|all|percentage> (e.g., /sell 0.05, /sell all, /sell 50%)"));
1140
+ messageLogBox.setScrollPerc(100);
1141
+ screen?.render();
1142
+ }
1143
+ inputBox?.clearValue();
1144
+ inputBox?.focus();
1145
+ screen?.render();
1146
+ return;
1147
+ }
1148
+ const amountStr = parts[1];
1149
+ inputBox?.setValue(`💸 Selling tokens...`);
1150
+ screen?.render();
1151
+ // Get token info to check balance
1152
+ if (!userAddress) {
1153
+ throw new Error("User address not available");
1154
+ }
1155
+ const tokenInfo = await getTokenInfo(client, tokenIdentifier, userAddress, true // silent mode
1156
+ );
1157
+ if (!tokenInfo.userPosition || tokenInfo.userPosition.tokensOwned === "0") {
1158
+ throw new Error("You do not own any of this token");
1159
+ }
1160
+ // Parse sell amount
1161
+ const tokenAmount = parseTokenAmount(amountStr, tokenInfo.userPosition.tokensOwned);
1162
+ const result = await sellToken(client, tokenIdentifier, tokenAmount, true // silent mode
1163
+ );
1164
+ if (messageLogBox) {
1165
+ messageLogBox.log(formatSellResultCompact(result));
1166
+ messageLogBox.setScrollPerc(100);
1167
+ screen?.render();
1168
+ }
1169
+ inputBox?.clearValue();
1170
+ inputBox?.focus();
1171
+ screen?.render();
1172
+ }
1173
+ catch (error) {
1174
+ const errorMsg = error instanceof Error ? error.message : String(error);
1175
+ if (messageLogBox) {
1176
+ messageLogBox.log(chalk.red(`❌ Sell failed: ${errorMsg}`));
1177
+ messageLogBox.setScrollPerc(100);
1178
+ screen?.render();
1179
+ }
1180
+ inputBox?.clearValue();
1181
+ inputBox?.focus();
1182
+ screen?.render();
1183
+ }
1184
+ return;
1185
+ }
955
1186
  case "/help":
956
1187
  if (messageLogBox) {
957
- messageLogBox.log(chalk.dim("Commands: /exit, /quit, /renew, /help"));
1188
+ const helpText = tokenIdentifier
1189
+ ? chalk.dim("Commands: /exit, /quit, /renew, /buy, /sell, /help")
1190
+ : chalk.dim("Commands: /exit, /quit, /renew, /help");
1191
+ messageLogBox.log(helpText);
958
1192
  messageLogBox.setScrollPerc(100);
959
1193
  screen?.render();
960
1194
  }
@@ -1116,7 +1350,10 @@ export async function startChatStream(client, jsonMode = false, tokenIdentifier)
1116
1350
  await new Promise((resolve) => setTimeout(resolve, 2000));
1117
1351
  // Reconnect with new lease
1118
1352
  if (wsUrl) {
1119
- const wsUrlObj = new URL(wsUrl);
1353
+ // Normalize WebSocket URL protocol based on agent URL
1354
+ const agentUrl = client.getAgentUrl();
1355
+ const normalizedWsUrl = normalizeWebSocketUrl(wsUrl, agentUrl);
1356
+ const wsUrlObj = new URL(normalizedWsUrl);
1120
1357
  wsUrlObj.searchParams.set("leaseId", leaseInfo.leaseId);
1121
1358
  wsUrl = wsUrlObj.toString();
1122
1359
  // Retry logic
@@ -1200,8 +1437,87 @@ export async function startChatStream(client, jsonMode = false, tokenIdentifier)
1200
1437
  }
1201
1438
  return;
1202
1439
  }
1440
+ case "/buy": {
1441
+ // Only allow in token-specific chats
1442
+ if (!tokenIdentifier) {
1443
+ console.log(chalk.yellow("⚠️ /buy command only works in token-specific chats"));
1444
+ if (readlineInterface)
1445
+ readlineInterface.prompt();
1446
+ return;
1447
+ }
1448
+ try {
1449
+ const parts = trimmed.split(" ");
1450
+ if (parts.length < 2) {
1451
+ console.log(chalk.yellow("⚠️ Usage: /buy <amount> (e.g., /buy 0.05)"));
1452
+ if (readlineInterface)
1453
+ readlineInterface.prompt();
1454
+ return;
1455
+ }
1456
+ const amount = parts[1];
1457
+ console.log(chalk.yellow("💰 Buying tokens..."));
1458
+ const isTestMode = client.getNetwork().includes("sepolia");
1459
+ const result = await buyToken(client, tokenIdentifier, amount, isTestMode, true // silent mode
1460
+ );
1461
+ console.log(formatBuyResultCompact(result));
1462
+ if (readlineInterface)
1463
+ readlineInterface.prompt();
1464
+ }
1465
+ catch (error) {
1466
+ const errorMsg = error instanceof Error ? error.message : String(error);
1467
+ console.log(chalk.red(`❌ Buy failed: ${errorMsg}`));
1468
+ if (readlineInterface)
1469
+ readlineInterface.prompt();
1470
+ }
1471
+ return;
1472
+ }
1473
+ case "/sell": {
1474
+ // Only allow in token-specific chats
1475
+ if (!tokenIdentifier) {
1476
+ console.log(chalk.yellow("⚠️ /sell command only works in token-specific chats"));
1477
+ if (readlineInterface)
1478
+ readlineInterface.prompt();
1479
+ return;
1480
+ }
1481
+ try {
1482
+ const parts = trimmed.split(" ");
1483
+ if (parts.length < 2) {
1484
+ console.log(chalk.yellow("⚠️ Usage: /sell <amount|all|percentage> (e.g., /sell 0.05, /sell all, /sell 50%)"));
1485
+ if (readlineInterface)
1486
+ readlineInterface.prompt();
1487
+ return;
1488
+ }
1489
+ const amountStr = parts[1];
1490
+ console.log(chalk.yellow("💸 Selling tokens..."));
1491
+ // Get token info to check balance
1492
+ if (!userAddress) {
1493
+ throw new Error("User address not available");
1494
+ }
1495
+ const tokenInfo = await getTokenInfo(client, tokenIdentifier, userAddress, true // silent mode
1496
+ );
1497
+ if (!tokenInfo.userPosition || tokenInfo.userPosition.tokensOwned === "0") {
1498
+ throw new Error("You do not own any of this token");
1499
+ }
1500
+ // Parse sell amount
1501
+ const tokenAmount = parseTokenAmount(amountStr, tokenInfo.userPosition.tokensOwned);
1502
+ const result = await sellToken(client, tokenIdentifier, tokenAmount, true // silent mode
1503
+ );
1504
+ console.log(formatSellResultCompact(result));
1505
+ if (readlineInterface)
1506
+ readlineInterface.prompt();
1507
+ }
1508
+ catch (error) {
1509
+ const errorMsg = error instanceof Error ? error.message : String(error);
1510
+ console.log(chalk.red(`❌ Sell failed: ${errorMsg}`));
1511
+ if (readlineInterface)
1512
+ readlineInterface.prompt();
1513
+ }
1514
+ return;
1515
+ }
1203
1516
  case "/help":
1204
- console.log(chalk.dim("Commands: /exit, /quit, /renew, /help"));
1517
+ const helpText = tokenIdentifier
1518
+ ? chalk.dim("Commands: /exit, /quit, /renew, /buy, /sell, /help")
1519
+ : chalk.dim("Commands: /exit, /quit, /renew, /help");
1520
+ console.log(helpText);
1205
1521
  if (readlineInterface)
1206
1522
  readlineInterface.prompt();
1207
1523
  return;
@@ -1306,11 +1622,241 @@ export async function startChatStream(client, jsonMode = false, tokenIdentifier)
1306
1622
  }
1307
1623
  }
1308
1624
  else {
1309
- // JSON mode: keep process alive and stream messages
1625
+ // JSON mode: read messages from stdin and output JSON to stdout
1626
+ // Set up stdin reading for non-interactive mode
1627
+ process.stdin.setEncoding('utf8');
1628
+ process.stdin.setRawMode(false);
1629
+ process.stdin.resume();
1630
+ let stdinBuffer = '';
1631
+ process.stdin.on('data', async (chunk) => {
1632
+ stdinBuffer += chunk.toString();
1633
+ const lines = stdinBuffer.split('\n');
1634
+ stdinBuffer = lines.pop() || ''; // Keep incomplete line in buffer
1635
+ for (const line of lines) {
1636
+ const trimmed = line.trim();
1637
+ if (!trimmed)
1638
+ continue;
1639
+ // Handle commands
1640
+ if (trimmed.startsWith('/')) {
1641
+ const [cmd] = trimmed.split(' ');
1642
+ switch (cmd) {
1643
+ case '/exit':
1644
+ case '/quit':
1645
+ isExiting = true;
1646
+ if (leaseCheckInterval)
1647
+ clearInterval(leaseCheckInterval);
1648
+ if (headerUpdateInterval)
1649
+ clearInterval(headerUpdateInterval);
1650
+ if (ws)
1651
+ ws.close();
1652
+ console.log(JSON.stringify({ type: 'exiting' }));
1653
+ process.exit(0);
1654
+ return;
1655
+ case '/renew': {
1656
+ try {
1657
+ console.log(JSON.stringify({ type: 'renewing_lease' }));
1658
+ const renewal = await renewLease(client, leaseInfo?.leaseId);
1659
+ leaseInfo = {
1660
+ leaseId: renewal.leaseId,
1661
+ leaseExpiresAt: new Date(renewal.leaseExpiresAt),
1662
+ };
1663
+ console.log(JSON.stringify({
1664
+ type: 'lease_renewed',
1665
+ leaseId: renewal.leaseId,
1666
+ leaseExpiresAt: renewal.leaseExpiresAt,
1667
+ }));
1668
+ // Close old connection
1669
+ if (ws) {
1670
+ ws.removeAllListeners();
1671
+ if (ws.readyState === 1 || ws.readyState === 0) {
1672
+ ws.close();
1673
+ }
1674
+ ws = null;
1675
+ }
1676
+ // Wait for database transaction
1677
+ await new Promise((resolve) => setTimeout(resolve, 2000));
1678
+ // Reconnect with new lease
1679
+ if (wsUrl) {
1680
+ const agentUrl = client.getAgentUrl();
1681
+ const normalizedWsUrl = normalizeWebSocketUrl(wsUrl, agentUrl);
1682
+ const wsUrlObj = new URL(normalizedWsUrl);
1683
+ wsUrlObj.searchParams.set("leaseId", leaseInfo.leaseId);
1684
+ wsUrl = wsUrlObj.toString();
1685
+ // Retry logic
1686
+ let retryCount = 0;
1687
+ const maxRetries = 3;
1688
+ let connected = false;
1689
+ while (!connected && retryCount < maxRetries) {
1690
+ try {
1691
+ if (retryCount > 0) {
1692
+ const backoffDelay = Math.min(1000 * Math.pow(2, retryCount - 1), 5000);
1693
+ await new Promise((resolve) => setTimeout(resolve, backoffDelay));
1694
+ }
1695
+ await new Promise((resolve, reject) => {
1696
+ const newWs = new WebSocket(wsUrl);
1697
+ attachWebSocketHandlers(newWs);
1698
+ let timeout = null;
1699
+ let openHandler = null;
1700
+ let closeHandler = null;
1701
+ let errorHandler = null;
1702
+ const cleanup = () => {
1703
+ if (timeout)
1704
+ clearTimeout(timeout);
1705
+ if (openHandler)
1706
+ newWs.removeListener("open", openHandler);
1707
+ if (closeHandler)
1708
+ newWs.removeListener("close", closeHandler);
1709
+ if (errorHandler)
1710
+ newWs.removeListener("error", errorHandler);
1711
+ };
1712
+ timeout = setTimeout(() => {
1713
+ cleanup();
1714
+ newWs.close();
1715
+ reject(new Error("Connection timeout"));
1716
+ }, 10000);
1717
+ openHandler = () => {
1718
+ cleanup();
1719
+ ws = newWs;
1720
+ resolve();
1721
+ };
1722
+ closeHandler = (code, reason) => {
1723
+ cleanup();
1724
+ const reasonStr = reason.toString();
1725
+ if (code === 1008 && reasonStr.includes("lease")) {
1726
+ reject(new Error("Lease validation failed"));
1727
+ }
1728
+ else {
1729
+ reject(new Error(`Connection closed: ${code} - ${reasonStr}`));
1730
+ }
1731
+ };
1732
+ errorHandler = (error) => {
1733
+ cleanup();
1734
+ reject(error);
1735
+ };
1736
+ newWs.once("open", openHandler);
1737
+ newWs.once("close", closeHandler);
1738
+ newWs.once("error", errorHandler);
1739
+ });
1740
+ connected = true;
1741
+ console.log(JSON.stringify({ type: 'reconnected' }));
1742
+ }
1743
+ catch (error) {
1744
+ retryCount++;
1745
+ const errorMsg = error instanceof Error ? error.message : String(error);
1746
+ if (retryCount >= maxRetries) {
1747
+ console.log(JSON.stringify({
1748
+ type: 'error',
1749
+ error: `Failed to reconnect after ${maxRetries} attempts: ${errorMsg}`,
1750
+ }));
1751
+ return;
1752
+ }
1753
+ }
1754
+ }
1755
+ }
1756
+ }
1757
+ catch (error) {
1758
+ const errorMsg = error instanceof Error ? error.message : String(error);
1759
+ console.log(JSON.stringify({
1760
+ type: 'error',
1761
+ error: errorMsg,
1762
+ }));
1763
+ }
1764
+ return;
1765
+ }
1766
+ default:
1767
+ console.log(JSON.stringify({
1768
+ type: 'error',
1769
+ error: `Unknown command: ${cmd}. Supported commands: /exit, /renew`,
1770
+ }));
1771
+ return;
1772
+ }
1773
+ }
1774
+ // Send message
1775
+ if (isSending) {
1776
+ console.log(JSON.stringify({
1777
+ type: 'error',
1778
+ error: 'Please wait for the previous message to be sent',
1779
+ }));
1780
+ return;
1781
+ }
1782
+ // Check if websocket is connected
1783
+ if (!ws || ws.readyState !== 1) {
1784
+ console.log(JSON.stringify({
1785
+ type: 'error',
1786
+ error: 'WebSocket is not connected. Please wait for connection or use /renew.',
1787
+ }));
1788
+ return;
1789
+ }
1790
+ // Check if lease is valid
1791
+ if (!leaseInfo || leaseInfo.leaseExpiresAt.getTime() <= Date.now()) {
1792
+ console.log(JSON.stringify({
1793
+ type: 'error',
1794
+ error: 'Lease has expired. Use /renew to continue chatting.',
1795
+ }));
1796
+ return;
1797
+ }
1798
+ // Send message
1799
+ isSending = true;
1800
+ try {
1801
+ leaseInfo = await ensureLeaseValid(client, leaseInfo, undefined, undefined);
1802
+ if (!ws || ws.readyState !== 1) {
1803
+ throw new Error("WebSocket connection lost. Please wait for reconnection.");
1804
+ }
1805
+ const result = await sendChatMessage(client, trimmed, leaseInfo.leaseId);
1806
+ userAddress = result.author;
1807
+ // Track this message
1808
+ const tempMessageId = `pending-${Date.now()}-${Math.random()}`;
1809
+ pendingMessages.set(result.messageId, tempMessageId);
1810
+ pendingMessages.set(`text:${trimmed}`, tempMessageId);
1811
+ // Message sent - will be displayed when received via WebSocket
1812
+ console.log(JSON.stringify({
1813
+ type: 'message_sent',
1814
+ messageId: result.messageId,
1815
+ }));
1816
+ }
1817
+ catch (error) {
1818
+ const errorMsg = error instanceof Error ? error.message : String(error);
1819
+ console.log(JSON.stringify({
1820
+ type: 'error',
1821
+ error: errorMsg,
1822
+ }));
1823
+ }
1824
+ finally {
1825
+ isSending = false;
1826
+ }
1827
+ }
1828
+ });
1829
+ process.stdin.on('end', () => {
1830
+ // Stdin closed - this is normal in JSON mode when input pipe ends
1831
+ isExiting = true;
1832
+ if (leaseCheckInterval)
1833
+ clearInterval(leaseCheckInterval);
1834
+ if (headerUpdateInterval)
1835
+ clearInterval(headerUpdateInterval);
1836
+ if (ws)
1837
+ ws.close();
1838
+ // Don't try to use readline in JSON mode - it should never exist
1839
+ process.exit(0);
1840
+ });
1841
+ process.stdin.on('error', (error) => {
1842
+ // Handle stdin errors gracefully in JSON mode
1843
+ if (!isExiting) {
1844
+ console.log(JSON.stringify({
1845
+ type: 'error',
1846
+ error: `Stdin error: ${error.message}`,
1847
+ }));
1848
+ }
1849
+ });
1310
1850
  process.on("SIGINT", () => {
1311
1851
  isExiting = true;
1852
+ if (leaseCheckInterval)
1853
+ clearInterval(leaseCheckInterval);
1854
+ if (headerUpdateInterval)
1855
+ clearInterval(headerUpdateInterval);
1312
1856
  if (ws)
1313
1857
  ws.close();
1858
+ // Don't try to use readline in JSON mode - it should never exist
1859
+ console.log(JSON.stringify({ type: 'exiting' }));
1314
1860
  process.exit(0);
1315
1861
  });
1316
1862
  }
@@ -1340,7 +1886,8 @@ export async function startChatStream(client, jsonMode = false, tokenIdentifier)
1340
1886
  if (screen) {
1341
1887
  screen.destroy();
1342
1888
  }
1343
- if (rl) {
1889
+ // Only close readline if not in JSON mode (readline should never exist in JSON mode)
1890
+ if (rl && !jsonMode) {
1344
1891
  rl.close();
1345
1892
  }
1346
1893
  }