ptn 0.3.1__tar.gz → 0.4.2__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 (84) hide show
  1. {ptn-0.3.1 → ptn-0.4.2}/PKG-INFO +54 -16
  2. {ptn-0.3.1 → ptn-0.4.2}/README.md +53 -15
  3. {ptn-0.3.1 → ptn-0.4.2}/porterminal/_version.py +2 -2
  4. {ptn-0.3.1 → ptn-0.4.2}/porterminal/application/services/management_service.py +28 -52
  5. {ptn-0.3.1 → ptn-0.4.2}/porterminal/application/services/session_service.py +3 -11
  6. {ptn-0.3.1 → ptn-0.4.2}/porterminal/application/services/terminal_service.py +97 -56
  7. {ptn-0.3.1 → ptn-0.4.2}/porterminal/cli/args.py +39 -31
  8. {ptn-0.3.1 → ptn-0.4.2}/porterminal/cli/display.py +18 -16
  9. ptn-0.4.2/porterminal/cli/script_discovery.py +112 -0
  10. {ptn-0.3.1 → ptn-0.4.2}/porterminal/composition.py +2 -7
  11. {ptn-0.3.1 → ptn-0.4.2}/porterminal/config.py +4 -2
  12. {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/__init__.py +0 -9
  13. ptn-0.4.2/porterminal/domain/entities/output_buffer.py +124 -0
  14. {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/entities/tab.py +11 -10
  15. {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/services/__init__.py +0 -2
  16. {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/values/__init__.py +0 -4
  17. {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/values/environment_rules.py +3 -0
  18. {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/cloudflared.py +13 -11
  19. {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/config/shell_detector.py +115 -16
  20. {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/repositories/in_memory_session.py +1 -4
  21. {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/repositories/in_memory_tab.py +2 -10
  22. ptn-0.4.2/porterminal/pty/env.py +35 -0
  23. {ptn-0.3.1 → ptn-0.4.2}/porterminal/pty/manager.py +6 -4
  24. ptn-0.4.2/porterminal/static/assets/app-DlWNJWFE.js +87 -0
  25. ptn-0.4.2/porterminal/static/assets/app-xPAM7YhQ.css +1 -0
  26. {ptn-0.3.1 → ptn-0.4.2}/porterminal/static/index.html +2 -2
  27. {ptn-0.3.1 → ptn-0.4.2}/porterminal/updater.py +13 -5
  28. ptn-0.3.1/porterminal/domain/entities/output_buffer.py +0 -69
  29. ptn-0.3.1/porterminal/pty/env.py +0 -97
  30. ptn-0.3.1/porterminal/static/assets/app-BkHv5qu0.css +0 -32
  31. ptn-0.3.1/porterminal/static/assets/app-CaIGfw7i.js +0 -72
  32. ptn-0.3.1/porterminal/static/assets/app-D9ELFbEO.js +0 -72
  33. ptn-0.3.1/porterminal/static/assets/app-DF3nl_io.js +0 -72
  34. ptn-0.3.1/porterminal/static/assets/app-DQePboVd.css +0 -32
  35. ptn-0.3.1/porterminal/static/assets/app-DoBiVkTD.js +0 -72
  36. ptn-0.3.1/porterminal/static/assets/app-azbHOsRw.css +0 -32
  37. ptn-0.3.1/porterminal/static/assets/app-nMNFwMa6.css +0 -32
  38. {ptn-0.3.1 → ptn-0.4.2}/.gitignore +0 -0
  39. {ptn-0.3.1 → ptn-0.4.2}/LICENSE +0 -0
  40. {ptn-0.3.1 → ptn-0.4.2}/porterminal/__init__.py +0 -0
  41. {ptn-0.3.1 → ptn-0.4.2}/porterminal/__main__.py +0 -0
  42. {ptn-0.3.1 → ptn-0.4.2}/porterminal/app.py +0 -0
  43. {ptn-0.3.1 → ptn-0.4.2}/porterminal/application/__init__.py +0 -0
  44. {ptn-0.3.1 → ptn-0.4.2}/porterminal/application/ports/__init__.py +0 -0
  45. {ptn-0.3.1 → ptn-0.4.2}/porterminal/application/ports/connection_port.py +0 -0
  46. {ptn-0.3.1 → ptn-0.4.2}/porterminal/application/ports/connection_registry_port.py +0 -0
  47. {ptn-0.3.1 → ptn-0.4.2}/porterminal/application/services/__init__.py +0 -0
  48. {ptn-0.3.1 → ptn-0.4.2}/porterminal/application/services/tab_service.py +0 -0
  49. {ptn-0.3.1 → ptn-0.4.2}/porterminal/asgi.py +0 -0
  50. {ptn-0.3.1 → ptn-0.4.2}/porterminal/cli/__init__.py +0 -0
  51. {ptn-0.3.1 → ptn-0.4.2}/porterminal/container.py +0 -0
  52. {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/entities/__init__.py +0 -0
  53. {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/entities/session.py +0 -0
  54. {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/ports/__init__.py +0 -0
  55. {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/ports/pty_port.py +0 -0
  56. {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/ports/session_repository.py +0 -0
  57. {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/ports/tab_repository.py +0 -0
  58. {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/services/environment_sanitizer.py +0 -0
  59. {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/services/rate_limiter.py +0 -0
  60. {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/services/session_limits.py +0 -0
  61. {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/services/tab_limits.py +0 -0
  62. {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/values/rate_limit_config.py +0 -0
  63. {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/values/session_id.py +0 -0
  64. {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/values/shell_command.py +0 -0
  65. {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/values/tab_id.py +0 -0
  66. {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/values/terminal_dimensions.py +0 -0
  67. {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/values/user_id.py +0 -0
  68. {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/__init__.py +0 -0
  69. {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/auth.py +0 -0
  70. {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/config/__init__.py +0 -0
  71. {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/network.py +0 -0
  72. {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/registry/__init__.py +0 -0
  73. {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/registry/user_connection_registry.py +0 -0
  74. {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/repositories/__init__.py +0 -0
  75. {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/server.py +0 -0
  76. {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/web/__init__.py +0 -0
  77. {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/web/websocket_adapter.py +0 -0
  78. {ptn-0.3.1 → ptn-0.4.2}/porterminal/logging_setup.py +0 -0
  79. {ptn-0.3.1 → ptn-0.4.2}/porterminal/pty/__init__.py +0 -0
  80. {ptn-0.3.1 → ptn-0.4.2}/porterminal/pty/protocol.py +0 -0
  81. {ptn-0.3.1 → ptn-0.4.2}/porterminal/pty/unix.py +0 -0
  82. {ptn-0.3.1 → ptn-0.4.2}/porterminal/pty/windows.py +0 -0
  83. {ptn-0.3.1 → ptn-0.4.2}/porterminal/static/icon.svg +0 -0
  84. {ptn-0.3.1 → ptn-0.4.2}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ptn
3
- Version: 0.3.1
3
+ Version: 0.4.2
4
4
  Summary: Web-based terminal accessible from phone via Cloudflare Tunnel
5
5
  Project-URL: Homepage, https://github.com/lyehe/porterminal
6
6
  Project-URL: Repository, https://github.com/lyehe/porterminal
@@ -75,9 +75,10 @@ So I built something simpler: **run a command, scan a QR, start typing.**
75
75
  ## Features
76
76
 
77
77
  - **One command, instant access** - No SSH, no port forwarding, no config files. Cloudflare tunnel + QR code.
78
- - **Actually usable on mobile** - Essential buttons and gestures for everyday terminal use.
79
- - **Multi-tab shared sessions** - Run builds in one tab, tail logs in another. Sessions and tabs persist across reconnects.
80
- - **Cross-platform** - Windows (PowerShell, CMD, WSL), Linux/macOS (Bash, Zsh, Fish). Auto-detects your shells.
78
+ - **Actually usable on mobile** - Touch-optimized with momentum scrolling, pinch-to-zoom, swipe gestures, and modifier keys (Ctrl, Alt).
79
+ - **Full terminal apps** - vim, htop, less, tmux all work correctly with proper alt-screen buffer handling.
80
+ - **Persistent multi-tab sessions** - Sessions survive disconnects. Close the browser, switch networks, reconnect from another device—your shell and running processes are still there. Multiple devices can view the same session simultaneously.
81
+ - **Cross-platform** - Windows (PowerShell, CMD, WSL), Linux/macOS (Bash, Zsh, Fish, Nushell, and any shell via `$SHELL`). Auto-detects your shells.
81
82
 
82
83
  ## Install
83
84
 
@@ -102,31 +103,66 @@ Requires Python 3.12+ and [cloudflared](https://developers.cloudflare.com/cloudf
102
103
  ```bash
103
104
  ptn # Start in current directory
104
105
  ptn ~/projects/myapp # Start in specific folder
105
- ptn --no-tunnel # Local network only
106
- ptn -b # Run in background
107
- ptn -p # Enable password protection
108
- ptn -dp # Toggle default password requirement in config
109
- ptn -v # Verbose startup logs
110
- ptn --init # Create .ptn/ptn.yaml config
111
- ptn -V # Show version
112
- ptn -U # Update to latest version
113
- ptn --check-update # Check if update available
114
106
  ```
115
107
 
108
+ | Flag | Description |
109
+ |------|-------------|
110
+ | `-n, --no-tunnel` | Local network only (no Cloudflare tunnel) |
111
+ | `-b, --background` | Run in background and return immediately |
112
+ | `-p, --password` | Prompt for password to protect this session |
113
+ | `-dp, --default-password` | Toggle password requirement in config (on/off) |
114
+ | `-v, --verbose` | Show detailed startup logs |
115
+ | `-i, --init` | Create `.ptn/ptn.yaml` config with auto-discovered project scripts |
116
+ | `-u, --update` | Update to the latest version |
117
+ | `-c, --check-update` | Check if a newer version is available |
118
+ | `-V, --version` | Show version |
119
+
120
+ ## Mobile Gestures
121
+
122
+ | Gesture | Action |
123
+ |---------|--------|
124
+ | **Tap** | Focus terminal, clear selection |
125
+ | **Long-press** | Start text selection |
126
+ | **Double-tap** | Select word |
127
+ | **Swipe left/right** | Arrow keys (← →) |
128
+ | **Scroll** | Momentum scrolling with physics |
129
+ | **Pinch** | Zoom text (10-24px) |
130
+
131
+ **Modifier keys** (Ctrl, Alt, Shift): Tap once for sticky (one keystroke), double-tap for lock.
132
+
116
133
  ## Configuration
117
134
 
118
- Run `ptn --init` to create a starter config, or create `ptn.yaml` manually:
135
+ Run `ptn --init` to create a starter config. It auto-discovers project scripts from `package.json`, `pyproject.toml`, or `Makefile` and adds them as buttons:
136
+
137
+ ```bash
138
+ ptn -i
139
+ # Created: .ptn/ptn.yaml
140
+ # Discovered 3 project script(s): build, dev, test
141
+ ```
142
+
143
+ Or create `ptn.yaml` manually:
119
144
 
120
145
  ```yaml
146
+ # Terminal settings
147
+ terminal:
148
+ default_shell: nu # Default shell ID
149
+ shells: # Custom shell definitions
150
+ - id: nu
151
+ name: Nushell
152
+ command: nu
153
+ args: []
154
+
121
155
  # Custom buttons (appear in toolbar)
156
+ # row: 1 = default row, 2+ = additional rows
122
157
  buttons:
123
158
  - label: "claude"
124
159
  send:
125
160
  - "claude"
126
161
  - 100 # delay in ms
127
162
  - "\r"
128
- - label: "tmux"
129
- send: "tmux\r"
163
+ - label: "build"
164
+ send: "npm run build\r"
165
+ row: 2 # second button row
130
166
 
131
167
  # Update checker settings
132
168
  update:
@@ -155,6 +191,8 @@ Password is per-session (never saved to disk). See [docs/security.md](docs/secur
155
191
 
156
192
  **Connection fails?** Cloudflare tunnel sometimes blocks connections. Restart the server (`Ctrl+C`, then `ptn`) to get a fresh tunnel URL.
157
193
 
194
+ **Shell not detected?** Set your `$SHELL` environment variable or configure shells in `ptn.yaml`.
195
+
158
196
  ## Contributing
159
197
 
160
198
  Issues and PRs welcome.
@@ -35,9 +35,10 @@ So I built something simpler: **run a command, scan a QR, start typing.**
35
35
  ## Features
36
36
 
37
37
  - **One command, instant access** - No SSH, no port forwarding, no config files. Cloudflare tunnel + QR code.
38
- - **Actually usable on mobile** - Essential buttons and gestures for everyday terminal use.
39
- - **Multi-tab shared sessions** - Run builds in one tab, tail logs in another. Sessions and tabs persist across reconnects.
40
- - **Cross-platform** - Windows (PowerShell, CMD, WSL), Linux/macOS (Bash, Zsh, Fish). Auto-detects your shells.
38
+ - **Actually usable on mobile** - Touch-optimized with momentum scrolling, pinch-to-zoom, swipe gestures, and modifier keys (Ctrl, Alt).
39
+ - **Full terminal apps** - vim, htop, less, tmux all work correctly with proper alt-screen buffer handling.
40
+ - **Persistent multi-tab sessions** - Sessions survive disconnects. Close the browser, switch networks, reconnect from another device—your shell and running processes are still there. Multiple devices can view the same session simultaneously.
41
+ - **Cross-platform** - Windows (PowerShell, CMD, WSL), Linux/macOS (Bash, Zsh, Fish, Nushell, and any shell via `$SHELL`). Auto-detects your shells.
41
42
 
42
43
  ## Install
43
44
 
@@ -62,31 +63,66 @@ Requires Python 3.12+ and [cloudflared](https://developers.cloudflare.com/cloudf
62
63
  ```bash
63
64
  ptn # Start in current directory
64
65
  ptn ~/projects/myapp # Start in specific folder
65
- ptn --no-tunnel # Local network only
66
- ptn -b # Run in background
67
- ptn -p # Enable password protection
68
- ptn -dp # Toggle default password requirement in config
69
- ptn -v # Verbose startup logs
70
- ptn --init # Create .ptn/ptn.yaml config
71
- ptn -V # Show version
72
- ptn -U # Update to latest version
73
- ptn --check-update # Check if update available
74
66
  ```
75
67
 
68
+ | Flag | Description |
69
+ |------|-------------|
70
+ | `-n, --no-tunnel` | Local network only (no Cloudflare tunnel) |
71
+ | `-b, --background` | Run in background and return immediately |
72
+ | `-p, --password` | Prompt for password to protect this session |
73
+ | `-dp, --default-password` | Toggle password requirement in config (on/off) |
74
+ | `-v, --verbose` | Show detailed startup logs |
75
+ | `-i, --init` | Create `.ptn/ptn.yaml` config with auto-discovered project scripts |
76
+ | `-u, --update` | Update to the latest version |
77
+ | `-c, --check-update` | Check if a newer version is available |
78
+ | `-V, --version` | Show version |
79
+
80
+ ## Mobile Gestures
81
+
82
+ | Gesture | Action |
83
+ |---------|--------|
84
+ | **Tap** | Focus terminal, clear selection |
85
+ | **Long-press** | Start text selection |
86
+ | **Double-tap** | Select word |
87
+ | **Swipe left/right** | Arrow keys (← →) |
88
+ | **Scroll** | Momentum scrolling with physics |
89
+ | **Pinch** | Zoom text (10-24px) |
90
+
91
+ **Modifier keys** (Ctrl, Alt, Shift): Tap once for sticky (one keystroke), double-tap for lock.
92
+
76
93
  ## Configuration
77
94
 
78
- Run `ptn --init` to create a starter config, or create `ptn.yaml` manually:
95
+ Run `ptn --init` to create a starter config. It auto-discovers project scripts from `package.json`, `pyproject.toml`, or `Makefile` and adds them as buttons:
96
+
97
+ ```bash
98
+ ptn -i
99
+ # Created: .ptn/ptn.yaml
100
+ # Discovered 3 project script(s): build, dev, test
101
+ ```
102
+
103
+ Or create `ptn.yaml` manually:
79
104
 
80
105
  ```yaml
106
+ # Terminal settings
107
+ terminal:
108
+ default_shell: nu # Default shell ID
109
+ shells: # Custom shell definitions
110
+ - id: nu
111
+ name: Nushell
112
+ command: nu
113
+ args: []
114
+
81
115
  # Custom buttons (appear in toolbar)
116
+ # row: 1 = default row, 2+ = additional rows
82
117
  buttons:
83
118
  - label: "claude"
84
119
  send:
85
120
  - "claude"
86
121
  - 100 # delay in ms
87
122
  - "\r"
88
- - label: "tmux"
89
- send: "tmux\r"
123
+ - label: "build"
124
+ send: "npm run build\r"
125
+ row: 2 # second button row
90
126
 
91
127
  # Update checker settings
92
128
  update:
@@ -115,6 +151,8 @@ Password is per-session (never saved to disk). See [docs/security.md](docs/secur
115
151
 
116
152
  **Connection fails?** Cloudflare tunnel sometimes blocks connections. Restart the server (`Ctrl+C`, then `ptn`) to get a fresh tunnel URL.
117
153
 
154
+ **Shell not detected?** Set your `$SHELL` environment variable or configure shells in `ptn.yaml`.
155
+
118
156
  ## Contributing
119
157
 
120
158
  Issues and PRs welcome.
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.3.1'
32
- __version_tuple__ = version_tuple = (0, 3, 1)
31
+ __version__ = version = '0.4.2'
32
+ __version_tuple__ = version_tuple = (0, 4, 2)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -36,6 +36,23 @@ class ManagementService:
36
36
  self._get_shell = shell_provider
37
37
  self._default_dims = default_dimensions
38
38
 
39
+ async def _send_error(
40
+ self,
41
+ connection: ConnectionPort,
42
+ response_type: str,
43
+ request_id: str,
44
+ error: str,
45
+ ) -> None:
46
+ """Send an error response to a connection."""
47
+ await connection.send_message(
48
+ {
49
+ "type": response_type,
50
+ "request_id": request_id,
51
+ "success": False,
52
+ "error": error,
53
+ }
54
+ )
55
+
39
56
  async def handle_message(
40
57
  self,
41
58
  user_id: UserId,
@@ -76,13 +93,8 @@ class ManagementService:
76
93
  # Get shell
77
94
  shell = self._get_shell(shell_id)
78
95
  if not shell:
79
- await connection.send_message(
80
- {
81
- "type": "create_tab_response",
82
- "request_id": request_id,
83
- "success": False,
84
- "error": "Invalid shell",
85
- }
96
+ await self._send_error(
97
+ connection, "create_tab_response", request_id, "Invalid shell"
86
98
  )
87
99
  return
88
100
 
@@ -127,14 +139,7 @@ class ManagementService:
127
139
 
128
140
  except ValueError as e:
129
141
  logger.warning("Tab creation failed: %s", e)
130
- await connection.send_message(
131
- {
132
- "type": "create_tab_response",
133
- "request_id": request_id,
134
- "success": False,
135
- "error": str(e),
136
- }
137
- )
142
+ await self._send_error(connection, "create_tab_response", request_id, str(e))
138
143
 
139
144
  async def _handle_close_tab(
140
145
  self,
@@ -147,27 +152,13 @@ class ManagementService:
147
152
  tab_id = message.get("tab_id")
148
153
 
149
154
  if not tab_id:
150
- await connection.send_message(
151
- {
152
- "type": "close_tab_response",
153
- "request_id": request_id,
154
- "success": False,
155
- "error": "Missing tab_id",
156
- }
157
- )
155
+ await self._send_error(connection, "close_tab_response", request_id, "Missing tab_id")
158
156
  return
159
157
 
160
158
  # Get tab and session info before closing
161
159
  tab = self._tab_service.get_tab(tab_id)
162
160
  if not tab:
163
- await connection.send_message(
164
- {
165
- "type": "close_tab_response",
166
- "request_id": request_id,
167
- "success": False,
168
- "error": "Tab not found",
169
- }
170
- )
161
+ await self._send_error(connection, "close_tab_response", request_id, "Tab not found")
171
162
  return
172
163
 
173
164
  session_id = tab.session_id
@@ -175,13 +166,8 @@ class ManagementService:
175
166
  # Close the tab
176
167
  closed_tab = self._tab_service.close_tab(tab_id, user_id)
177
168
  if not closed_tab:
178
- await connection.send_message(
179
- {
180
- "type": "close_tab_response",
181
- "request_id": request_id,
182
- "success": False,
183
- "error": "Failed to close tab",
184
- }
169
+ await self._send_error(
170
+ connection, "close_tab_response", request_id, "Failed to close tab"
185
171
  )
186
172
  return
187
173
 
@@ -222,26 +208,16 @@ class ManagementService:
222
208
  new_name = message.get("name")
223
209
 
224
210
  if not tab_id or not new_name:
225
- await connection.send_message(
226
- {
227
- "type": "rename_tab_response",
228
- "request_id": request_id,
229
- "success": False,
230
- "error": "Missing tab_id or name",
231
- }
211
+ await self._send_error(
212
+ connection, "rename_tab_response", request_id, "Missing tab_id or name"
232
213
  )
233
214
  return
234
215
 
235
216
  # Rename the tab
236
217
  tab = self._tab_service.rename_tab(tab_id, user_id, new_name)
237
218
  if not tab:
238
- await connection.send_message(
239
- {
240
- "type": "rename_tab_response",
241
- "request_id": request_id,
242
- "success": False,
243
- "error": "Failed to rename tab",
244
- }
219
+ await self._send_error(
220
+ connection, "rename_tab_response", request_id, "Failed to rename tab"
245
221
  )
246
222
  return
247
223
 
@@ -2,14 +2,11 @@
2
2
 
3
3
  import asyncio
4
4
  import logging
5
- import os
6
5
  import uuid
7
6
  from collections.abc import Awaitable, Callable
8
7
  from datetime import UTC, datetime
9
8
 
10
9
  from porterminal.domain import (
11
- EnvironmentRules,
12
- EnvironmentSanitizer,
13
10
  PTYPort,
14
11
  Session,
15
12
  SessionId,
@@ -32,17 +29,13 @@ class SessionService:
32
29
  def __init__(
33
30
  self,
34
31
  repository: SessionRepository[PTYPort],
35
- pty_factory: Callable[
36
- [ShellCommand, TerminalDimensions, dict[str, str], str | None], PTYPort
37
- ],
32
+ pty_factory: Callable[[ShellCommand, TerminalDimensions, str | None], PTYPort],
38
33
  limit_checker: SessionLimitChecker | None = None,
39
- environment_sanitizer: EnvironmentSanitizer | None = None,
40
34
  working_directory: str | None = None,
41
35
  ) -> None:
42
36
  self._repository = repository
43
37
  self._pty_factory = pty_factory
44
38
  self._limit_checker = limit_checker or SessionLimitChecker()
45
- self._sanitizer = environment_sanitizer or EnvironmentSanitizer(EnvironmentRules())
46
39
  self._cwd = working_directory
47
40
  self._running = False
48
41
  self._cleanup_task: asyncio.Task | None = None
@@ -108,9 +101,8 @@ class SessionService:
108
101
  if not limit_result.allowed:
109
102
  raise ValueError(limit_result.reason)
110
103
 
111
- # Create PTY with sanitized environment
112
- env = self._sanitizer.sanitize(dict(os.environ))
113
- pty = self._pty_factory(shell, dimensions, env, self._cwd)
104
+ # Create PTY (environment sanitization handled by PTY layer)
105
+ pty = self._pty_factory(shell, dimensions, self._cwd)
114
106
 
115
107
  # Create session (starts with 0 clients, caller adds via add_client())
116
108
  now = datetime.now(UTC)
@@ -3,7 +3,9 @@
3
3
  import asyncio
4
4
  import logging
5
5
  import re
6
+ import time
6
7
  from contextlib import suppress
8
+ from dataclasses import dataclass
7
9
  from datetime import UTC, datetime
8
10
  from typing import Any
9
11
 
@@ -19,6 +21,20 @@ from ..ports.connection_port import ConnectionPort
19
21
 
20
22
  logger = logging.getLogger(__name__)
21
23
 
24
+
25
+ @dataclass
26
+ class ConnectionFlowState:
27
+ """Per-connection flow control state.
28
+
29
+ Implements xterm.js recommended watermark-based flow control.
30
+ When client sends 'pause', we stop sending to that connection.
31
+ When client sends 'ack', we resume sending.
32
+ """
33
+
34
+ paused: bool = False
35
+ pause_time: float | None = None
36
+
37
+
22
38
  # Terminal response sequences that should NOT be written to PTY.
23
39
  # These are responses from the terminal emulator to queries from applications.
24
40
  # If written to PTY, they get echoed back and displayed as garbage.
@@ -31,11 +47,20 @@ TERMINAL_RESPONSE_PATTERN = re.compile(rb"\x1b\[\?[\d;]*c|\x1b\[[\d;]*R")
31
47
  # Constants
32
48
  HEARTBEAT_INTERVAL = 30 # seconds
33
49
  HEARTBEAT_TIMEOUT = 300 # 5 minutes
34
- PTY_READ_INTERVAL = 0.008 # ~120Hz polling
35
- OUTPUT_BATCH_INTERVAL = 0.016 # ~60Hz output (batch writes for smoother rendering)
50
+
51
+ # Adaptive PTY read interval: fast when data flowing, slow when idle
52
+ PTY_READ_INTERVAL_MIN = 0.001 # 1ms when data is flowing (high throughput)
53
+ PTY_READ_INTERVAL_MAX = 0.008 # 8ms when idle (save CPU)
54
+ PTY_READ_BURST_THRESHOLD = 5 # Consecutive reads with data before going fast
55
+
56
+ # Tiered batch intervals: faster for interactive, slower for bulk
57
+ OUTPUT_BATCH_INTERVAL_INTERACTIVE = 0.004 # 4ms for small data (<256 bytes)
58
+ OUTPUT_BATCH_INTERVAL_BULK = 0.016 # 16ms for larger data
59
+ OUTPUT_BATCH_SIZE_THRESHOLD = 256 # Bytes - threshold for interactive vs bulk
36
60
  OUTPUT_BATCH_MAX_SIZE = 16384 # Flush if batch exceeds 16KB
37
- INTERACTIVE_THRESHOLD = 64 # Bytes - flush immediately for small interactive data
61
+ INTERACTIVE_THRESHOLD = 64 # Bytes - flush immediately for very small data
38
62
  MAX_INPUT_SIZE = 4096
63
+ FLOW_PAUSE_TIMEOUT = 5.0 # seconds - auto-resume if client stops sending ACKs (was 15s)
39
64
 
40
65
 
41
66
  class AsyncioClock:
@@ -65,6 +90,8 @@ class TerminalService:
65
90
  self._session_read_tasks: dict[str, asyncio.Task[None]] = {}
66
91
  # Per-session locks to prevent race between buffer replay and broadcast
67
92
  self._session_locks: dict[str, asyncio.Lock] = {}
93
+ # Per-connection flow control state (watermark-based backpressure)
94
+ self._flow_state: dict[ConnectionPort, ConnectionFlowState] = {}
68
95
 
69
96
  # -------------------------------------------------------------------------
70
97
  # Multi-client connection tracking
@@ -72,9 +99,7 @@ class TerminalService:
72
99
 
73
100
  def _get_session_lock(self, session_id: str) -> asyncio.Lock:
74
101
  """Get or create a lock for a session."""
75
- if session_id not in self._session_locks:
76
- self._session_locks[session_id] = asyncio.Lock()
77
- return self._session_locks[session_id]
102
+ return self._session_locks.setdefault(session_id, asyncio.Lock())
78
103
 
79
104
  def _cleanup_session_lock(self, session_id: str) -> None:
80
105
  """Remove session lock when no longer needed."""
@@ -82,13 +107,17 @@ class TerminalService:
82
107
 
83
108
  def _register_connection(self, session_id: str, connection: ConnectionPort) -> int:
84
109
  """Register a connection for a session. Returns connection count."""
85
- if session_id not in self._session_connections:
86
- self._session_connections[session_id] = set()
87
- self._session_connections[session_id].add(connection)
88
- return len(self._session_connections[session_id])
110
+ connections = self._session_connections.setdefault(session_id, set())
111
+ connections.add(connection)
112
+ # Initialize flow control state for this connection
113
+ self._flow_state[connection] = ConnectionFlowState()
114
+ return len(connections)
89
115
 
90
116
  def _unregister_connection(self, session_id: str, connection: ConnectionPort) -> int:
91
117
  """Unregister a connection. Returns remaining count."""
118
+ # Clean up flow control state
119
+ self._flow_state.pop(connection, None)
120
+
92
121
  if session_id not in self._session_connections:
93
122
  return 0
94
123
  self._session_connections[session_id].discard(connection)
@@ -98,12 +127,27 @@ class TerminalService:
98
127
  return count
99
128
 
100
129
  async def _send_to_connections(self, connections: list[ConnectionPort], data: bytes) -> None:
101
- """Send data to a list of connections (used with pre-snapshotted list)."""
130
+ """Send data to connections, respecting flow control.
131
+
132
+ Skips paused connections (client overwhelmed) but auto-resumes
133
+ after FLOW_PAUSE_TIMEOUT to prevent permanent pause from dead clients.
134
+ """
135
+ current_time = time.time()
102
136
  for conn in connections:
137
+ flow = self._flow_state.get(conn)
138
+ if flow and flow.paused:
139
+ # Check timeout - auto-resume if client stopped responding
140
+ if flow.pause_time and (current_time - flow.pause_time) > FLOW_PAUSE_TIMEOUT:
141
+ flow.paused = False
142
+ flow.pause_time = None
143
+ logger.debug("Auto-resumed paused connection after timeout")
144
+ else:
145
+ continue # Skip paused connection
146
+
103
147
  try:
104
148
  await conn.send_output(data)
105
- except Exception:
106
- pass # Connection cleanup handled elsewhere
149
+ except Exception as e:
150
+ logger.debug("Failed to send output to connection: %s", e)
107
151
 
108
152
  async def _broadcast_output(self, session_id: str, data: bytes) -> None:
109
153
  """Broadcast PTY output to all connections for a session.
@@ -264,6 +308,7 @@ class TerminalService:
264
308
  batch_buffer: list[bytes] = []
265
309
  batch_size = 0
266
310
  last_flush_time = asyncio.get_running_loop().time()
311
+ consecutive_data_reads = 0 # Track consecutive reads with data for adaptive sleep
267
312
 
268
313
  async def flush_batch() -> None:
269
314
  """Flush batched data with lock protection."""
@@ -295,6 +340,10 @@ class TerminalService:
295
340
  data = session.pty_handle.read(4096)
296
341
  if data:
297
342
  session.touch(datetime.now(UTC))
343
+ # Track consecutive reads with data for adaptive sleep
344
+ consecutive_data_reads = min(
345
+ consecutive_data_reads + 1, PTY_READ_BURST_THRESHOLD
346
+ )
298
347
 
299
348
  # Small data (interactive): flush immediately for responsiveness
300
349
  if len(data) < INTERACTIVE_THRESHOLD and not batch_buffer:
@@ -312,6 +361,9 @@ class TerminalService:
312
361
  # Flush if batch is large enough
313
362
  if batch_size >= OUTPUT_BATCH_MAX_SIZE:
314
363
  await flush_batch()
364
+ else:
365
+ # No data - reset burst counter
366
+ consecutive_data_reads = 0
315
367
 
316
368
  except Exception as e:
317
369
  logger.error("PTY read error session_id=%s: %s", session.id, e)
@@ -319,12 +371,25 @@ class TerminalService:
319
371
  await self._broadcast_output(session_id, f"\r\n[PTY error: {e}]\r\n".encode())
320
372
  break
321
373
 
374
+ # Tiered batch interval: faster for small batches, slower for large
375
+ batch_interval = (
376
+ OUTPUT_BATCH_INTERVAL_INTERACTIVE
377
+ if batch_size < OUTPUT_BATCH_SIZE_THRESHOLD
378
+ else OUTPUT_BATCH_INTERVAL_BULK
379
+ )
380
+
322
381
  # Check if we should flush based on time
323
382
  current_time = asyncio.get_running_loop().time()
324
- if batch_buffer and (current_time - last_flush_time) >= OUTPUT_BATCH_INTERVAL:
383
+ if batch_buffer and (current_time - last_flush_time) >= batch_interval:
325
384
  await flush_batch()
326
385
 
327
- await asyncio.sleep(PTY_READ_INTERVAL)
386
+ # Adaptive sleep: fast when data flowing, slow when idle
387
+ sleep_time = (
388
+ PTY_READ_INTERVAL_MIN
389
+ if consecutive_data_reads >= PTY_READ_BURST_THRESHOLD
390
+ else PTY_READ_INTERVAL_MAX
391
+ )
392
+ await asyncio.sleep(sleep_time)
328
393
 
329
394
  # Flush any remaining data
330
395
  await flush_batch()
@@ -358,7 +423,7 @@ class TerminalService:
358
423
  if isinstance(message, bytes):
359
424
  await self._handle_binary_input(session, message, rate_limiter, connection)
360
425
  elif isinstance(message, dict):
361
- await self._handle_json_message(session, message, rate_limiter, connection)
426
+ await self._handle_json_message(session, message, connection)
362
427
 
363
428
  async def _handle_binary_input(
364
429
  self,
@@ -400,7 +465,6 @@ class TerminalService:
400
465
  self,
401
466
  session: Session[PTYPort],
402
467
  message: dict[str, Any],
403
- rate_limiter: TokenBucketRateLimiter,
404
468
  connection: ConnectionPort,
405
469
  ) -> None:
406
470
  """Handle JSON control message."""
@@ -408,13 +472,27 @@ class TerminalService:
408
472
 
409
473
  if msg_type == "resize":
410
474
  await self._handle_resize(session, message, connection)
411
- elif msg_type == "input":
412
- await self._handle_json_input(session, message, rate_limiter, connection)
413
475
  elif msg_type == "ping":
414
476
  await connection.send_message({"type": "pong"})
415
477
  session.touch(datetime.now(UTC))
416
478
  elif msg_type == "pong":
417
479
  session.touch(datetime.now(UTC))
480
+ elif msg_type == "pause":
481
+ # Client is overwhelmed - stop sending data to this connection
482
+ flow = self._flow_state.get(connection)
483
+ if flow:
484
+ flow.paused = True
485
+ flow.pause_time = time.time()
486
+ # Send confirmation so client knows pause was received
487
+ await connection.send_message({"type": "pause_ack"})
488
+ logger.debug("Connection paused (client overwhelmed) session_id=%s", session.id)
489
+ elif msg_type == "ack":
490
+ # Client caught up - resume sending data
491
+ flow = self._flow_state.get(connection)
492
+ if flow and flow.paused:
493
+ flow.paused = False
494
+ flow.pause_time = None
495
+ logger.debug("Connection resumed (client caught up) session_id=%s", session.id)
418
496
  else:
419
497
  logger.warning("Unknown message type session_id=%s type=%s", session.id, msg_type)
420
498
 
@@ -475,40 +553,3 @@ class TerminalService:
475
553
  new_dims.cols,
476
554
  new_dims.rows,
477
555
  )
478
-
479
- async def _handle_json_input(
480
- self,
481
- session: Session[PTYPort],
482
- message: dict[str, Any],
483
- rate_limiter: TokenBucketRateLimiter,
484
- connection: ConnectionPort,
485
- ) -> None:
486
- """Handle JSON-encoded terminal input."""
487
- data = message.get("data", "")
488
-
489
- if len(data) > self._max_input_size:
490
- await connection.send_message(
491
- {
492
- "type": "error",
493
- "message": "Input too large",
494
- }
495
- )
496
- return
497
-
498
- if data:
499
- input_bytes = data.encode("utf-8")
500
- # Filter terminal response sequences
501
- filtered = TERMINAL_RESPONSE_PATTERN.sub(b"", input_bytes)
502
- if not filtered:
503
- return
504
-
505
- if rate_limiter.try_acquire(len(filtered)):
506
- session.pty_handle.write(filtered)
507
- session.touch(datetime.now(UTC))
508
- else:
509
- await connection.send_message(
510
- {
511
- "type": "error",
512
- "message": "Rate limit exceeded",
513
- }
514
- )