replx 1.8__tar.gz → 1.9__tar.gz

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.
Files changed (153) hide show
  1. {replx-1.8/replx.egg-info → replx-1.9}/PKG-INFO +4 -2
  2. {replx-1.8 → replx-1.9}/README.md +3 -1
  3. {replx-1.8 → replx-1.9}/replx/__init__.py +1 -1
  4. {replx-1.8 → replx-1.9}/replx/cli/__init__.py +2 -2
  5. {replx-1.8 → replx-1.9}/replx/cli/agent/client/core.py +51 -16
  6. {replx-1.8 → replx-1.9}/replx/cli/agent/client/session.py +7 -0
  7. {replx-1.8 → replx-1.9}/replx/cli/agent/server/connection_manager.py +28 -87
  8. {replx-1.8 → replx-1.9}/replx/cli/agent/server/core.py +21 -2
  9. {replx-1.8 → replx-1.9}/replx/cli/agent/server/handlers/exec.py +38 -8
  10. {replx-1.8 → replx-1.9}/replx/cli/agent/server/session_manager.py +3 -0
  11. {replx-1.8 → replx-1.9}/replx/cli/app.py +40 -33
  12. replx-1.9/replx/cli/commands/_common.py +122 -0
  13. {replx-1.8 → replx-1.9}/replx/cli/commands/adc.py +2 -21
  14. {replx-1.8 → replx-1.9}/replx/cli/commands/device.py +178 -935
  15. {replx-1.8 → replx-1.9}/replx/cli/commands/exec.py +67 -53
  16. {replx-1.8 → replx-1.9}/replx/cli/commands/file.py +77 -22
  17. {replx-1.8 → replx-1.9}/replx/cli/commands/firmware.py +33 -9
  18. {replx-1.8 → replx-1.9}/replx/cli/commands/gpio.py +1 -14
  19. {replx-1.8 → replx-1.9}/replx/cli/commands/i2c.py +1 -50
  20. {replx-1.8 → replx-1.9}/replx/cli/commands/package.py +112 -27
  21. {replx-1.8 → replx-1.9}/replx/cli/commands/pwm.py +4 -16
  22. {replx-1.8 → replx-1.9}/replx/cli/commands/spi.py +12 -109
  23. {replx-1.8 → replx-1.9}/replx/cli/commands/uart.py +4 -97
  24. {replx-1.8 → replx-1.9}/replx/cli/commands/utility.py +231 -151
  25. replx-1.9/replx/cli/commands/wifi.py +914 -0
  26. replx-1.9/replx/cli/config.py +763 -0
  27. {replx-1.8 → replx-1.9}/replx/cli/connection.py +37 -41
  28. {replx-1.8 → replx-1.9}/replx/cli/helpers/compiler.py +54 -24
  29. replx-1.9/replx/cli/helpers/output.py +610 -0
  30. {replx-1.8 → replx-1.9}/replx/cli/helpers/registry.py +16 -1
  31. {replx-1.8 → replx-1.9}/replx/utils/constants.py +2 -1
  32. {replx-1.8 → replx-1.9/replx.egg-info}/PKG-INFO +4 -2
  33. {replx-1.8 → replx-1.9}/replx.egg-info/SOURCES.txt +2 -0
  34. replx-1.8/replx/cli/config.py +0 -461
  35. replx-1.8/replx/cli/helpers/output.py +0 -219
  36. {replx-1.8 → replx-1.9}/LICENSE +0 -0
  37. {replx-1.8 → replx-1.9}/pyproject.toml +0 -0
  38. {replx-1.8 → replx-1.9}/replx/cli/agent/__init__.py +0 -0
  39. {replx-1.8 → replx-1.9}/replx/cli/agent/client/__init__.py +0 -0
  40. {replx-1.8 → replx-1.9}/replx/cli/agent/protocol.py +0 -0
  41. {replx-1.8 → replx-1.9}/replx/cli/agent/server/__init__.py +0 -0
  42. {replx-1.8 → replx-1.9}/replx/cli/agent/server/__main__.py +0 -0
  43. {replx-1.8 → replx-1.9}/replx/cli/agent/server/command_dispatcher.py +0 -0
  44. {replx-1.8 → replx-1.9}/replx/cli/agent/server/handlers/__init__.py +0 -0
  45. {replx-1.8 → replx-1.9}/replx/cli/agent/server/handlers/filesystem.py +0 -0
  46. {replx-1.8 → replx-1.9}/replx/cli/agent/server/handlers/i2c.py +0 -0
  47. {replx-1.8 → replx-1.9}/replx/cli/agent/server/handlers/repl.py +0 -0
  48. {replx-1.8 → replx-1.9}/replx/cli/agent/server/handlers/session.py +0 -0
  49. {replx-1.8 → replx-1.9}/replx/cli/agent/server/handlers/spi.py +0 -0
  50. {replx-1.8 → replx-1.9}/replx/cli/agent/server/handlers/transfer.py +0 -0
  51. {replx-1.8 → replx-1.9}/replx/cli/agent/server/handlers/uart.py +0 -0
  52. {replx-1.8 → replx-1.9}/replx/cli/commands/__init__.py +0 -0
  53. {replx-1.8 → replx-1.9}/replx/cli/helpers/__init__.py +0 -0
  54. {replx-1.8 → replx-1.9}/replx/cli/helpers/environment.py +0 -0
  55. {replx-1.8 → replx-1.9}/replx/cli/helpers/scanner.py +0 -0
  56. {replx-1.8 → replx-1.9}/replx/cli/helpers/store.py +0 -0
  57. {replx-1.8 → replx-1.9}/replx/cli/helpers/updater.py +0 -0
  58. {replx-1.8 → replx-1.9}/replx/commands.py +0 -0
  59. {replx-1.8 → replx-1.9}/replx/protocol/__init__.py +0 -0
  60. {replx-1.8 → replx-1.9}/replx/protocol/repl.py +0 -0
  61. {replx-1.8 → replx-1.9}/replx/protocol/storage.py +0 -0
  62. {replx-1.8 → replx-1.9}/replx/terminal.py +0 -0
  63. {replx-1.8 → replx-1.9}/replx/transport/__init__.py +0 -0
  64. {replx-1.8 → replx-1.9}/replx/transport/serial.py +0 -0
  65. {replx-1.8 → replx-1.9}/replx/typehints/comm/_thread.pyi +0 -0
  66. {replx-1.8 → replx-1.9}/replx/typehints/comm/aioble/__init__.pyi +0 -0
  67. {replx-1.8 → replx-1.9}/replx/typehints/comm/array.pyi +0 -0
  68. {replx-1.8 → replx-1.9}/replx/typehints/comm/asyncio/__init__.pyi +0 -0
  69. {replx-1.8 → replx-1.9}/replx/typehints/comm/binascii.pyi +0 -0
  70. {replx-1.8 → replx-1.9}/replx/typehints/comm/bluetooth.pyi +0 -0
  71. {replx-1.8 → replx-1.9}/replx/typehints/comm/builtins.pyi +0 -0
  72. {replx-1.8 → replx-1.9}/replx/typehints/comm/cmath.pyi +0 -0
  73. {replx-1.8 → replx-1.9}/replx/typehints/comm/collections.pyi +0 -0
  74. {replx-1.8 → replx-1.9}/replx/typehints/comm/cryptolib.pyi +0 -0
  75. {replx-1.8 → replx-1.9}/replx/typehints/comm/deflate.pyi +0 -0
  76. {replx-1.8 → replx-1.9}/replx/typehints/comm/errno.pyi +0 -0
  77. {replx-1.8 → replx-1.9}/replx/typehints/comm/framebuf.pyi +0 -0
  78. {replx-1.8 → replx-1.9}/replx/typehints/comm/gc.pyi +0 -0
  79. {replx-1.8 → replx-1.9}/replx/typehints/comm/hashlib.pyi +0 -0
  80. {replx-1.8 → replx-1.9}/replx/typehints/comm/heapq.pyi +0 -0
  81. {replx-1.8 → replx-1.9}/replx/typehints/comm/io.pyi +0 -0
  82. {replx-1.8 → replx-1.9}/replx/typehints/comm/json.pyi +0 -0
  83. {replx-1.8 → replx-1.9}/replx/typehints/comm/lwip.pyi +0 -0
  84. {replx-1.8 → replx-1.9}/replx/typehints/comm/machine.pyi +0 -0
  85. {replx-1.8 → replx-1.9}/replx/typehints/comm/math.pyi +0 -0
  86. {replx-1.8 → replx-1.9}/replx/typehints/comm/micropython.pyi +0 -0
  87. {replx-1.8 → replx-1.9}/replx/typehints/comm/mip/__init__.pyi +0 -0
  88. {replx-1.8 → replx-1.9}/replx/typehints/comm/network.pyi +0 -0
  89. {replx-1.8 → replx-1.9}/replx/typehints/comm/ntptime.pyi +0 -0
  90. {replx-1.8 → replx-1.9}/replx/typehints/comm/os.pyi +0 -0
  91. {replx-1.8 → replx-1.9}/replx/typehints/comm/platform.pyi +0 -0
  92. {replx-1.8 → replx-1.9}/replx/typehints/comm/random.pyi +0 -0
  93. {replx-1.8 → replx-1.9}/replx/typehints/comm/re.pyi +0 -0
  94. {replx-1.8 → replx-1.9}/replx/typehints/comm/requests/__init__.pyi +0 -0
  95. {replx-1.8 → replx-1.9}/replx/typehints/comm/select.pyi +0 -0
  96. {replx-1.8 → replx-1.9}/replx/typehints/comm/socket.pyi +0 -0
  97. {replx-1.8 → replx-1.9}/replx/typehints/comm/ssl.pyi +0 -0
  98. {replx-1.8 → replx-1.9}/replx/typehints/comm/struct.pyi +0 -0
  99. {replx-1.8 → replx-1.9}/replx/typehints/comm/sys.pyi +0 -0
  100. {replx-1.8 → replx-1.9}/replx/typehints/comm/time.pyi +0 -0
  101. {replx-1.8 → replx-1.9}/replx/typehints/comm/tls.pyi +0 -0
  102. {replx-1.8 → replx-1.9}/replx/typehints/comm/uasyncio.pyi +0 -0
  103. {replx-1.8 → replx-1.9}/replx/typehints/comm/uctypes.pyi +0 -0
  104. {replx-1.8 → replx-1.9}/replx/typehints/comm/urequests.pyi +0 -0
  105. {replx-1.8 → replx-1.9}/replx/typehints/comm/vfs.pyi +0 -0
  106. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/binascii.pyi +0 -0
  107. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/digi/__init__.pyi +0 -0
  108. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/digi/ble.pyi +0 -0
  109. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/errno.pyi +0 -0
  110. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/hashlib.pyi +0 -0
  111. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/io.pyi +0 -0
  112. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/json.pyi +0 -0
  113. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/machine.pyi +0 -0
  114. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/math.pyi +0 -0
  115. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/micropython.pyi +0 -0
  116. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/network.pyi +0 -0
  117. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/os.pyi +0 -0
  118. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/select.pyi +0 -0
  119. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/socket.pyi +0 -0
  120. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/ssl.pyi +0 -0
  121. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/struct.pyi +0 -0
  122. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/sys.pyi +0 -0
  123. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/time.pyi +0 -0
  124. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/ubinascii.pyi +0 -0
  125. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/ucryptolib.pyi +0 -0
  126. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/uerrno.pyi +0 -0
  127. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/uhashlib.pyi +0 -0
  128. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/uio.pyi +0 -0
  129. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/ujson.pyi +0 -0
  130. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/umachine.pyi +0 -0
  131. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/uos.pyi +0 -0
  132. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/uselect.pyi +0 -0
  133. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/usocket.pyi +0 -0
  134. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/ussl.pyi +0 -0
  135. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/ustruct.pyi +0 -0
  136. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/utime.pyi +0 -0
  137. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/xbee/__init__.pyi +0 -0
  138. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/xbee/modem_status.pyi +0 -0
  139. {replx-1.8 → replx-1.9}/replx/typehints/comm_separate/EFR32MG/xbee/relay.pyi +0 -0
  140. {replx-1.8 → replx-1.9}/replx/typehints/core/ESP32/aioespnow.pyi +0 -0
  141. {replx-1.8 → replx-1.9}/replx/typehints/core/ESP32/esp.pyi +0 -0
  142. {replx-1.8 → replx-1.9}/replx/typehints/core/ESP32/esp32.pyi +0 -0
  143. {replx-1.8 → replx-1.9}/replx/typehints/core/ESP32/espnow.pyi +0 -0
  144. {replx-1.8 → replx-1.9}/replx/typehints/core/MIMXRT1062DVJ6A/mimxrt.pyi +0 -0
  145. {replx-1.8 → replx-1.9}/replx/typehints/core/RP2350/rp2.pyi +0 -0
  146. {replx-1.8 → replx-1.9}/replx/utils/__init__.py +0 -0
  147. {replx-1.8 → replx-1.9}/replx/utils/device_info.py +0 -0
  148. {replx-1.8 → replx-1.9}/replx/utils/exceptions.py +0 -0
  149. {replx-1.8 → replx-1.9}/replx.egg-info/dependency_links.txt +0 -0
  150. {replx-1.8 → replx-1.9}/replx.egg-info/entry_points.txt +0 -0
  151. {replx-1.8 → replx-1.9}/replx.egg-info/requires.txt +0 -0
  152. {replx-1.8 → replx-1.9}/replx.egg-info/top_level.txt +0 -0
  153. {replx-1.8 → replx-1.9}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: replx
3
- Version: 1.8
3
+ Version: 1.9
4
4
  Summary: replx is a fast, modern MicroPython CLI: turbo REPL, robust file sync (put/get), project install, mpy-cross integration, and smart port discovery.
5
5
  Author-email: "chanmin.park" <devcamp@gmail.com>
6
6
  License-Expression: MIT
@@ -30,11 +30,12 @@ Dynamic: license-file
30
30
  [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
31
31
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
32
32
 
33
- `replx` is a CLI tool for MicroPython development. It uses an agent-based architecture to manage multiple CLI sessions and multiple boards in a consistent workflow.
33
+ `replx` is a CLI tool for MicroPython development. It uses a single local agent process to manage multiple CLI sessions and multiple boards in a consistent workflow.
34
34
 
35
35
  ## What replx provides
36
36
 
37
37
  - Shared connection management across terminal sessions
38
+ - Single local agent process per PC with home-scoped port persistence
38
39
  - Foreground and background board handling per session
39
40
  - Workspace-level default device configuration
40
41
  - File operations on device storage
@@ -104,6 +105,7 @@ pip install replx
104
105
 
105
106
  - `scan`, `status`, `whoami`, and `shutdown` are special commands and do not accept `--port`.
106
107
  - Most device commands can omit the port when a foreground or workspace default device is available.
108
+ - The agent listens on a UDP port from `49152-65535` and stores the selected port in `~/.replx/.config` as `AGENT_PORT=...`.
107
109
 
108
110
  ## License
109
111
 
@@ -4,11 +4,12 @@
4
4
  [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
6
 
7
- `replx` is a CLI tool for MicroPython development. It uses an agent-based architecture to manage multiple CLI sessions and multiple boards in a consistent workflow.
7
+ `replx` is a CLI tool for MicroPython development. It uses a single local agent process to manage multiple CLI sessions and multiple boards in a consistent workflow.
8
8
 
9
9
  ## What replx provides
10
10
 
11
11
  - Shared connection management across terminal sessions
12
+ - Single local agent process per PC with home-scoped port persistence
12
13
  - Foreground and background board handling per session
13
14
  - Workspace-level default device configuration
14
15
  - File operations on device storage
@@ -78,6 +79,7 @@ pip install replx
78
79
 
79
80
  - `scan`, `status`, `whoami`, and `shutdown` are special commands and do not accept `--port`.
80
81
  - Most device commands can omit the port when a foreground or workspace default device is available.
82
+ - The agent listens on a UDP port from `49152-65535` and stores the selected port in `~/.replx/.config` as `AGENT_PORT=...`.
81
83
 
82
84
  ## License
83
85
 
@@ -1,5 +1,5 @@
1
1
  __all__ = ["__version__", "get_version", "__description__"]
2
- __version__ = "1.8"
2
+ __version__ = "1.9"
3
3
  __description__ = "Fast, modern MicroPython CLI with REPL, file sync, install, and smart port detection."
4
4
  __author__ = "PlanX Lab Development Team"
5
5
 
@@ -2,12 +2,12 @@ from .config import (
2
2
  RuntimeState, STATE, GLOBAL_OPTIONS,
3
3
  ConfigManager, AgentPortManager, ConnectionResolver,
4
4
  )
5
- from replx.utils.constants import DEFAULT_AGENT_PORT, MAX_AGENT_PORT
5
+ from replx.utils.constants import DEFAULT_AGENT_PORT, MIN_AGENT_PORT, MAX_AGENT_PORT
6
6
  from .app import app, main
7
7
 
8
8
  __all__ = [
9
9
  'RuntimeState', 'STATE', 'GLOBAL_OPTIONS',
10
10
  'ConfigManager', 'AgentPortManager', 'ConnectionResolver',
11
- 'DEFAULT_AGENT_PORT', 'MAX_AGENT_PORT',
11
+ 'DEFAULT_AGENT_PORT', 'MIN_AGENT_PORT', 'MAX_AGENT_PORT',
12
12
  'app', 'main'
13
13
  ]
@@ -128,35 +128,64 @@ class AgentClient:
128
128
  seq = request['seq']
129
129
  request_data = AgentProtocol.encode_message(request)
130
130
 
131
- self.sock.sendto(request_data, (AGENT_HOST, self.agent_port))
132
-
133
- self.sock.settimeout(5.0)
131
+ # UDP can drop ACK packets. Retry start request a few times while
132
+ # waiting for ACK/early stream for this seq.
133
+ self.sock.settimeout(0.2)
134
134
  ack_received = False
135
135
  error_response = None
136
+ completed_during_handshake = False
136
137
 
137
- while True:
138
- try:
139
- data, addr = self.sock.recvfrom(MAX_UDP_SIZE)
140
- msg = AgentProtocol.decode_message(data)
141
- if msg and msg.get('seq') == seq:
142
- if msg.get('type') == 'ack':
143
- ack_received = True
144
- break
145
- elif msg.get('type') == 'response' and msg.get('error'):
146
- error_response = msg
147
- break
148
- except socket.timeout:
138
+ max_attempts = max(1, self.MAX_RETRIES)
139
+ for attempt in range(max_attempts):
140
+ self.sock.sendto(request_data, (AGENT_HOST, self.agent_port))
141
+
142
+ attempt_deadline = time.time() + 1.5
143
+ while time.time() < attempt_deadline:
144
+ try:
145
+ data, addr = self.sock.recvfrom(MAX_UDP_SIZE)
146
+ msg = AgentProtocol.decode_message(data)
147
+ if msg and msg.get('seq') == seq:
148
+ msg_type = msg.get('type')
149
+ if msg_type == 'ack':
150
+ ack_received = True
151
+ break
152
+ if msg_type == 'response' and msg.get('error'):
153
+ error_response = msg
154
+ break
155
+ # If stream arrives before ACK (ACK loss/reordering),
156
+ # treat it as a successful start.
157
+ if msg_type == 'stream':
158
+ ack_received = True
159
+ output = msg.get('output', '')
160
+ if output and output_callback:
161
+ output_callback(output.encode('utf-8'), 'stdout')
162
+ if msg.get('completed'):
163
+ error = msg.get('error')
164
+ if error and output_callback:
165
+ output_callback(error.encode('utf-8'), 'stderr')
166
+ completed_during_handshake = True
167
+ break
168
+ except socket.timeout:
169
+ continue
170
+
171
+ if ack_received or error_response:
149
172
  break
150
173
 
151
174
  if error_response:
152
175
  raise RuntimeError(error_response['error'])
153
176
 
154
177
  if not ack_received:
155
- raise RuntimeError("No ACK from agent - run_interactive failed to start")
178
+ raise RuntimeError(f"No ACK from agent - run_interactive failed to start (attempts={max_attempts})")
179
+
180
+ if completed_during_handshake:
181
+ self.sock.settimeout(self.TIMEOUT)
182
+ return {"run": True, "completed": True}
156
183
 
157
184
  self.sock.settimeout(0.01)
158
185
  input_interval = 0.001
159
186
  last_input_time = 0
187
+ last_stream_time = time.time()
188
+ stream_timeout = 5.0 # 5 seconds without stream = connection lost
160
189
 
161
190
  try:
162
191
  while True:
@@ -181,6 +210,7 @@ class AgentClient:
181
210
  msg = AgentProtocol.decode_message(data)
182
211
  if msg and msg.get('seq') == seq:
183
212
  if msg.get('type') == 'stream':
213
+ last_stream_time = time.time()
184
214
  output = msg.get('output', '')
185
215
  if output and output_callback:
186
216
  output_callback(output.encode('utf-8'), 'stdout')
@@ -204,6 +234,10 @@ class AgentClient:
204
234
 
205
235
  now = time.time()
206
236
 
237
+ # Check for stream reception timeout (connection loss detection)
238
+ if now - last_stream_time > stream_timeout:
239
+ raise RuntimeError("Connection lost - no data from board for {}s".format(stream_timeout))
240
+
207
241
  if now - last_input_time >= input_interval:
208
242
  last_input_time = now
209
243
  if input_provider:
@@ -225,6 +259,7 @@ class AgentClient:
225
259
  if msg.get('type') == 'response' and msg.get('error'):
226
260
  _pending_error = msg['error']
227
261
  elif msg.get('type') == 'stream':
262
+ last_stream_time = time.time() # Update on any stream (even empty)
228
263
  output = msg.get('output', '')
229
264
  if output and output_callback:
230
265
  output_callback(output.encode('utf-8'), 'stdout')
@@ -4,14 +4,21 @@ import psutil
4
4
 
5
5
  def _find_terminal_process() -> Optional[dict]:
6
6
  shell_names = {
7
+ # Windows
7
8
  'powershell.exe', 'pwsh.exe', 'cmd.exe', 'bash.exe', 'zsh.exe', 'sh.exe', 'fish.exe',
8
9
  'windowsterminal.exe',
10
+ # Linux / macOS (no .exe)
11
+ 'bash', 'zsh', 'sh', 'fish', 'tcsh', 'csh', 'ksh', 'dash', 'pwsh',
9
12
  }
10
13
 
11
14
  ide_names = {
15
+ # Windows
12
16
  'code.exe',
13
17
  'conemu64.exe', 'conemu.exe',
14
18
  'pycharm.exe', 'pycharm64.exe', 'idea.exe', 'idea64.exe',
19
+ # Linux / macOS (no .exe)
20
+ 'code', 'code-oss',
21
+ 'pycharm', 'pycharm64', 'idea', 'idea64',
15
22
  }
16
23
 
17
24
  try:
@@ -217,9 +217,10 @@ class ConnectionManager:
217
217
  def __init__(self):
218
218
  self._connections: Dict[str, BoardConnection] = {}
219
219
  self._connections_lock = threading.RLock()
220
+ # Per-port events used to serialize concurrent create_serial_connection
221
+ # calls for the same port (prevents double serial-open race).
222
+ self._port_creating: Dict[str, threading.Event] = {}
220
223
  self._default_port: Optional[str] = None
221
- self._board_id_cache: Dict[str, Dict[str, str]] = {}
222
- self._cache_lock = threading.Lock()
223
224
 
224
225
  @property
225
226
  def default_port(self) -> Optional[str]:
@@ -299,12 +300,32 @@ class ConnectionManager:
299
300
  original_port = str(port).strip() if port is not None else ""
300
301
  port_key = self._canon_port(original_port)
301
302
 
303
+ # --- Phase 1: fast check + race serialisation ---
302
304
  with self._connections_lock:
303
305
  if port_key in self._connections:
304
306
  conn = self._connections[port_key]
305
307
  if conn.is_connected():
306
308
  return conn, None
309
+ # If another thread is already opening this port, get its event so
310
+ # we can wait for it instead of opening the same serial port twice.
311
+ if port_key in self._port_creating:
312
+ wait_event = self._port_creating[port_key]
313
+ our_event = None
314
+ else:
315
+ our_event = threading.Event()
316
+ self._port_creating[port_key] = our_event
317
+ wait_event = None
318
+
319
+ if wait_event is not None:
320
+ # Another thread is opening this port; wait then return its result.
321
+ wait_event.wait(timeout=30)
322
+ with self._connections_lock:
323
+ conn = self._connections.get(port_key)
324
+ if conn and conn.is_connected():
325
+ return conn, None
326
+ return None, "Connection creation timed out (concurrent attempt)"
307
327
 
328
+ # --- Phase 2: we are the creator ---
308
329
  try:
309
330
  from replx.transport import create_transport
310
331
 
@@ -355,97 +376,17 @@ class ConnectionManager:
355
376
 
356
377
  with self._connections_lock:
357
378
  self._connections[port_key] = conn
379
+ self._port_creating.pop(port_key, None)
358
380
 
381
+ our_event.set() # wake any threads that raced with us
359
382
  return conn, None
360
383
 
361
384
  except Exception as e:
385
+ with self._connections_lock:
386
+ self._port_creating.pop(port_key, None)
387
+ our_event.set() # unblock waiting threads so they get the error
362
388
  return None, str(e)
363
389
 
364
- def ensure_board_id(self, port: str) -> Optional[str]:
365
- conn = self.get_connection(port)
366
- if not conn or not conn.repl_protocol:
367
- return None
368
-
369
- if conn.board_id is not None:
370
- return conn.board_id
371
-
372
- try:
373
- result = conn.repl_protocol.exec("import machine; print(machine.unique_id().hex())")
374
- if result:
375
- if isinstance(result, bytes):
376
- result = result.decode('utf-8', errors='ignore')
377
- result = result.strip()
378
- if result:
379
- conn.board_id = result
380
- self._update_board_id_cache(port, conn.board_id)
381
- return conn.board_id
382
- except Exception:
383
- pass
384
-
385
- return None
386
-
387
- def _update_board_id_cache(self, port: str, board_id: str) -> None:
388
- with self._cache_lock:
389
- if board_id not in self._board_id_cache:
390
- self._board_id_cache[board_id] = {}
391
-
392
- self._board_id_cache[board_id]["serial_port"] = self._canon_port(port)
393
-
394
- def find_conflicting_by_board_id(self, port: str) -> Optional[str]:
395
- conn = self.get_connection(port)
396
- if not conn or not conn.board_id:
397
- return None
398
-
399
- with self._connections_lock:
400
- for key, other_conn in self._connections.items():
401
- if key == port:
402
- continue
403
-
404
- if not other_conn.is_connected():
405
- continue
406
-
407
- if other_conn.board_id is None:
408
- try:
409
- result = other_conn.repl_protocol.exec("import machine; print(machine.unique_id().hex())")
410
- if result:
411
- if isinstance(result, bytes):
412
- result = result.decode('utf-8', errors='ignore')
413
- result = result.strip()
414
- if result:
415
- other_conn.board_id = result
416
- except Exception:
417
- continue
418
-
419
- if other_conn.board_id and other_conn.board_id == conn.board_id:
420
- return key
421
-
422
- return None
423
-
424
- def resolve_board_conflict(self, port: str) -> Optional[str]:
425
- board_id = self.ensure_board_id(port)
426
- if not board_id:
427
- return None
428
-
429
- conflicting_port = self.find_conflicting_by_board_id(port)
430
- if conflicting_port:
431
- self.disconnect(conflicting_port)
432
-
433
- conn = self.get_connection(port)
434
- if conn and conn.repl_protocol:
435
- try:
436
- transport = conn.repl_protocol.transport
437
- transport.write(CTRL_C)
438
- time.sleep(0.05)
439
- transport.write(CTRL_B)
440
- time.sleep(0.1)
441
- transport.reset_input_buffer()
442
- except Exception:
443
- pass
444
-
445
- return conflicting_port
446
-
447
- return None
448
-
449
390
  def disconnect(self, port: str) -> bool:
450
391
  key = None
451
392
  with self._connections_lock:
@@ -452,8 +452,16 @@ class AgentServer(
452
452
 
453
453
  def _check_and_record_seq(self, client_addr: tuple, seq: int) -> bool:
454
454
  with self._last_seq_lock:
455
- if client_addr in self.last_seq and seq <= self.last_seq[client_addr]:
456
- return False
455
+ if client_addr in self.last_seq:
456
+ last = self.last_seq[client_addr]
457
+ # Only reject exact duplicates from the same UDP source.
458
+ #
459
+ # Using strict monotonic ordering across client restarts can
460
+ # incorrectly drop valid requests (e.g. process restart, seq
461
+ # wrap, or source-port reuse), which then appears as
462
+ # "No ACK from agent" on the client.
463
+ if seq == last:
464
+ return False
457
465
  self.last_seq[client_addr] = seq
458
466
  if len(self.last_seq) > self._MAX_LAST_SEQ:
459
467
  oldest = next(iter(self.last_seq))
@@ -488,6 +496,12 @@ class AgentServer(
488
496
  command = msg.get('command')
489
497
 
490
498
  if not self._check_and_record_seq(client_addr, seq):
499
+ # Duplicate request (same seq from same UDP source): re-ACK so
500
+ # client retransmits can recover from a previously lost ACK.
501
+ self._safe_send(
502
+ AgentProtocol.encode_message(AgentProtocol.create_ack(seq)),
503
+ client_addr,
504
+ )
491
505
  return
492
506
 
493
507
  self._safe_send(
@@ -532,6 +546,11 @@ class AgentServer(
532
546
 
533
547
  if msg_type == 'request':
534
548
  if not self._check_and_record_seq(client_addr, seq):
549
+ # Duplicate request (same seq from same UDP source):
550
+ # send ACK again so the client can proceed.
551
+ ack = AgentProtocol.create_ack(seq)
552
+ ack_data = AgentProtocol.encode_message(ack)
553
+ self._safe_send(ack_data, client_addr)
535
554
  return
536
555
 
537
556
  ack = AgentProtocol.create_ack(seq)
@@ -232,6 +232,7 @@ class ExecCommandsMixin:
232
232
  conn.interactive.thread = None
233
233
  else:
234
234
  send_error("Interactive session already active on this connection")
235
+ conn.release()
235
236
  return
236
237
 
237
238
  repl = conn.repl_protocol
@@ -242,6 +243,7 @@ class ExecCommandsMixin:
242
243
  if script_path:
243
244
  if not os.path.exists(script_path):
244
245
  send_error(f"Script not found: {script_path}")
246
+ conn.release()
245
247
  return
246
248
  with open(script_path, 'rb') as f:
247
249
  script_data = f.read()
@@ -249,6 +251,7 @@ class ExecCommandsMixin:
249
251
  script_data = script_content.encode('utf-8') if isinstance(script_content, str) else script_content
250
252
  else:
251
253
  send_error("Either script_path or script_content required")
254
+ conn.release()
252
255
  return
253
256
 
254
257
  conn.interactive.start(ctx.ppid, seq, client_addr, echo)
@@ -282,19 +285,29 @@ class ExecCommandsMixin:
282
285
  output_buffer = bytearray()
283
286
  buffer_lock = threading.Lock()
284
287
  last_flush_time = [time.time()]
288
+ last_stream_send_time = [time.time()]
285
289
  flush_timer_running = [True]
286
290
  BUFFER_FLUSH_SIZE = 4096
287
291
  FLUSH_INTERVAL = 0.05
292
+ KEEPALIVE_INTERVAL = 1.0
288
293
 
289
294
  def send_stream(output: str = '', completed: bool = False, error: str = None):
290
- try:
291
- msg = {'type': 'stream', 'seq': seq, 'output': output}
292
- if completed:
293
- msg['completed'] = True
294
- msg['error'] = error
295
- self._safe_send(AgentProtocol.encode_message(msg), client_addr)
296
- except Exception:
297
- pass
295
+ # When script completes (especially with error), retry sending to ensure client receives it
296
+ retry_count = 5 if completed else 1
297
+ retry_interval = 0.05 # 50ms between retries for completed messages
298
+
299
+ for attempt in range(retry_count):
300
+ try:
301
+ msg = {'type': 'stream', 'seq': seq, 'output': output}
302
+ if completed:
303
+ msg['completed'] = True
304
+ msg['error'] = error
305
+ self._safe_send(AgentProtocol.encode_message(msg), client_addr)
306
+ last_stream_send_time[0] = time.time()
307
+ if completed and attempt < retry_count - 1:
308
+ time.sleep(retry_interval)
309
+ except Exception:
310
+ pass
298
311
 
299
312
  def flush_buffer():
300
313
  with buffer_lock:
@@ -332,6 +345,10 @@ class ExecCommandsMixin:
332
345
  while flush_timer_running[0]:
333
346
  time.sleep(FLUSH_INTERVAL)
334
347
  flush_buffer()
348
+ # Keep interactive session alive for client-side timeout logic,
349
+ # even when the script is running silently with no output.
350
+ if time.time() - last_stream_send_time[0] >= KEEPALIVE_INTERVAL:
351
+ send_stream('')
335
352
 
336
353
  flush_thread = threading.Thread(target=flush_timer, daemon=True)
337
354
  flush_thread.start()
@@ -403,6 +420,19 @@ class ExecCommandsMixin:
403
420
 
404
421
  def _cmd_run_stop(self, ctx: CommandContext) -> dict:
405
422
  conn = ctx.connection
423
+
424
+ # RUN_STOP is in NON_REPL_COMMANDS so ctx.connection is always None.
425
+ # Resolve the connection directly via explicit_port or active interactive session.
426
+ if conn is None:
427
+ if ctx.explicit_port:
428
+ conn = _find_connection_by_port(self.connection_manager, ctx.explicit_port)
429
+ if conn is None:
430
+ # Find any connection that has an active interactive session owned by this ppid
431
+ for c in self.connection_manager.get_all_connections().values():
432
+ if c.interactive.active and c.interactive.is_owner(ctx.ppid):
433
+ conn = c
434
+ break
435
+
406
436
  if not conn:
407
437
  return {"stopped": False, "reason": "Not connected"}
408
438
 
@@ -217,6 +217,9 @@ class SessionManager:
217
217
  else:
218
218
  os.kill(pid, 0)
219
219
  return True
220
+ except PermissionError:
221
+ # Process exists but we lack permission to signal it — treat as alive.
222
+ return True
220
223
  except OSError:
221
224
  return False
222
225