ghostterm 1.3.0 → 2.0.0

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.
@@ -0,0 +1,336 @@
1
+ 'use strict';
2
+
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const pty = require('node-pty');
6
+ const EventEmitter = require('events');
7
+
8
+ const MAX_SESSIONS = 4;
9
+ const BUFFER_MAX = 512 * 1024; // 512KB per session
10
+ const BUFFER_TRIM_TO = 384 * 1024; // trim to 384KB
11
+ const FLUSH_INTERVAL = 50; // 50ms output batching
12
+
13
+ function defaultShell() {
14
+ if (process.platform === 'win32') {
15
+ // Use PowerShell if available (more reliable than cmd.exe for conpty)
16
+ const ps = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
17
+ const fs = require('fs');
18
+ if (fs.existsSync(ps)) return ps;
19
+ return process.env.COMSPEC || 'cmd.exe';
20
+ }
21
+ return process.env.SHELL || '/bin/bash';
22
+ }
23
+
24
+ function defaultCwd() {
25
+ const desktop = path.join(os.homedir(), 'Desktop');
26
+ try {
27
+ require('fs').accessSync(desktop);
28
+ return desktop;
29
+ } catch {
30
+ return os.homedir();
31
+ }
32
+ }
33
+
34
+ class PtySession {
35
+ constructor(id, cols, rows) {
36
+ this.id = id;
37
+ this.cols = cols || 80;
38
+ this.rows = rows || 24;
39
+ this.buffer = '';
40
+ this.bufferSeq = 0;
41
+ this.ptyProcess = null;
42
+ this._flushTimer = null;
43
+ this._pendingOutput = '';
44
+ this._onOutput = null; // set by manager
45
+ }
46
+
47
+ start() {
48
+ const shell = defaultShell();
49
+ const args = [];
50
+ const env = { ...process.env, TERM: 'xterm-256color' };
51
+ // Remove Claude Code env vars that prevent nested sessions
52
+ delete env.CLAUDECODE;
53
+ delete env.CLAUDE_CODE;
54
+
55
+ try {
56
+ this.ptyProcess = pty.spawn(shell, args, {
57
+ name: 'xterm-256color',
58
+ cols: this.cols,
59
+ rows: this.rows,
60
+ cwd: defaultCwd(),
61
+ env,
62
+ });
63
+ } catch (e) {
64
+ console.error('Failed to spawn PTY:', e.message);
65
+ if (this._onOutput) {
66
+ this._onOutput(this.id, `\r\n\x1b[31mFailed to start terminal: ${e.message}\x1b[0m\r\n`, null, null);
67
+ this._onOutput(this.id, null, 1, null);
68
+ }
69
+ return;
70
+ }
71
+
72
+ this.ptyProcess.onData((data) => {
73
+ this.buffer += data;
74
+ this.bufferSeq += data.length;
75
+
76
+ // Trim buffer if too large
77
+ if (this.buffer.length > BUFFER_MAX) {
78
+ this.buffer = this.buffer.slice(-BUFFER_TRIM_TO);
79
+ }
80
+
81
+ // Send immediately (like relay version) instead of batching
82
+ if (this._onOutput) {
83
+ this._onOutput(this.id, data, null, null);
84
+ }
85
+ });
86
+
87
+ this.ptyProcess.onExit(({ exitCode, signal }) => {
88
+ this._flush();
89
+ if (this._onOutput) {
90
+ this._onOutput(this.id, null, exitCode, signal);
91
+ }
92
+ });
93
+ }
94
+
95
+ _flush() {
96
+ this._flushTimer = null;
97
+ if (this._pendingOutput && this._onOutput) {
98
+ this._onOutput(this.id, this._pendingOutput, null, null);
99
+ this._pendingOutput = '';
100
+ }
101
+ }
102
+
103
+ write(data) {
104
+ if (this.ptyProcess) {
105
+ this.ptyProcess.write(data);
106
+ }
107
+ }
108
+
109
+ resize(cols, rows) {
110
+ this.cols = cols;
111
+ this.rows = rows;
112
+ if (this.ptyProcess) {
113
+ try {
114
+ this.ptyProcess.resize(cols, rows);
115
+ } catch {}
116
+ }
117
+ }
118
+
119
+ kill() {
120
+ if (this._flushTimer) {
121
+ clearTimeout(this._flushTimer);
122
+ this._flushTimer = null;
123
+ }
124
+ if (this.ptyProcess) {
125
+ try {
126
+ this.ptyProcess.kill();
127
+ } catch {}
128
+ this.ptyProcess = null;
129
+ }
130
+ }
131
+ }
132
+
133
+ class PtyManager extends EventEmitter {
134
+ constructor() {
135
+ super();
136
+ this.sessions = new Map(); // id -> PtySession
137
+ this._nextId = 1;
138
+ this._attachedId = null;
139
+ }
140
+
141
+ /**
142
+ * Handle incoming message from mobile client.
143
+ * Returns response messages to send back.
144
+ */
145
+ handleMessage(msg) {
146
+ const responses = [];
147
+
148
+ switch (msg.type) {
149
+ case 'create-session': {
150
+ if (this.sessions.size >= MAX_SESSIONS) {
151
+ responses.push({ type: 'error', message: `Max ${MAX_SESSIONS} sessions` });
152
+ break;
153
+ }
154
+ const id = String(this._nextId++);
155
+ const session = new PtySession(id, msg.cols || 80, msg.rows || 24);
156
+ session._onOutput = (sid, data, exitCode, signal) => {
157
+ if (data !== null) {
158
+ this.emit('send', { type: 'output', sessionId: sid, data });
159
+ }
160
+ if (exitCode !== null || signal !== null) {
161
+ this.emit('send', { type: 'exit', sessionId: sid, exitCode, signal });
162
+ this.sessions.delete(sid);
163
+ if (this._attachedId === sid) {
164
+ this._attachedId = null;
165
+ }
166
+ this._broadcastSessions();
167
+ }
168
+ };
169
+ session.start();
170
+ this.sessions.set(id, session);
171
+
172
+ // Auto-attach to new session
173
+ this._attachedId = id;
174
+ responses.push({ type: 'sessions', sessions: this._sessionList() });
175
+ responses.push({ type: 'attached', sessionId: id });
176
+
177
+ // Send initial history if any
178
+ if (session.buffer) {
179
+ responses.push({ type: 'history', sessionId: id, data: session.buffer, bufferSeq: session.bufferSeq });
180
+ }
181
+ break;
182
+ }
183
+
184
+ case 'close-session': {
185
+ const id = msg.sessionId || msg.data;
186
+ const session = this.sessions.get(id);
187
+ if (session) {
188
+ session.kill();
189
+ this.sessions.delete(id);
190
+ if (this._attachedId === id) {
191
+ this._attachedId = null;
192
+ }
193
+ responses.push({ type: 'exit', sessionId: id, exitCode: 0 });
194
+ responses.push({ type: 'sessions', sessions: this._sessionList() });
195
+ }
196
+ break;
197
+ }
198
+
199
+ case 'attach': {
200
+ const id = msg.sessionId || msg.data;
201
+ const session = this.sessions.get(id);
202
+ if (session) {
203
+ this._attachedId = id;
204
+ responses.push({ type: 'attached', sessionId: id });
205
+ responses.push({ type: 'history', sessionId: id, data: session.buffer, bufferSeq: session.bufferSeq });
206
+ } else {
207
+ responses.push({ type: 'error', message: `Session ${id} not found` });
208
+ }
209
+ break;
210
+ }
211
+
212
+ case 'sync': {
213
+ // Delta sync: send only new data since clientSeq (matches relay logic)
214
+ const session = this.sessions.get(msg.sessionId);
215
+ if (session) {
216
+ const clientSeq = msg.clientSeq;
217
+ const bufStart = session.bufferSeq - session.buffer.length;
218
+ if (clientSeq >= session.bufferSeq) {
219
+ // Client is up to date
220
+ responses.push({
221
+ type: 'delta',
222
+ sessionId: msg.sessionId,
223
+ data: '',
224
+ bufferSeq: session.bufferSeq,
225
+ });
226
+ } else if (clientSeq >= bufStart) {
227
+ // Client is behind but within buffer range — send slice
228
+ const offset = clientSeq - bufStart;
229
+ responses.push({
230
+ type: 'delta',
231
+ sessionId: msg.sessionId,
232
+ data: session.buffer.slice(offset),
233
+ bufferSeq: session.bufferSeq,
234
+ });
235
+ } else {
236
+ // Client is too far behind — send full buffer as history
237
+ responses.push({
238
+ type: 'history',
239
+ sessionId: msg.sessionId,
240
+ data: session.buffer,
241
+ bufferSeq: session.bufferSeq,
242
+ });
243
+ }
244
+ }
245
+ break;
246
+ }
247
+
248
+ case 'input': {
249
+ const targetId = msg.sessionId || this._attachedId;
250
+ const session = targetId ? this.sessions.get(targetId) : null;
251
+ if (session) {
252
+ session.write(msg.data);
253
+ }
254
+ break;
255
+ }
256
+
257
+ case 'resize': {
258
+ const targetId = msg.sessionId || this._attachedId;
259
+ const session = targetId ? this.sessions.get(targetId) : null;
260
+ if (session && msg.cols && msg.rows) {
261
+ session.resize(msg.cols, msg.rows);
262
+ }
263
+ break;
264
+ }
265
+
266
+ case 'list-sessions': {
267
+ responses.push({ type: 'sessions', sessions: this._sessionList() });
268
+ break;
269
+ }
270
+
271
+ case 'file-upload': {
272
+ // Handle file upload: write base64 content to temp file
273
+ this._handleFileUpload(msg, responses);
274
+ break;
275
+ }
276
+
277
+ default:
278
+ break;
279
+ }
280
+
281
+ return responses;
282
+ }
283
+
284
+ _handleFileUpload(msg, responses) {
285
+ const fs = require('fs');
286
+ const tmpDir = os.tmpdir();
287
+ const filename = msg.filename || `upload_${Date.now()}${msg.ext || ''}`;
288
+ const filepath = path.join(tmpDir, filename);
289
+
290
+ try {
291
+ const data = Buffer.from(msg.data, 'base64');
292
+ fs.writeFileSync(filepath, data);
293
+ responses.push({
294
+ type: 'file-uploaded',
295
+ path: filepath,
296
+ size: data.length,
297
+ });
298
+
299
+ // If attached to a session, optionally paste the path
300
+ if (msg.pastePath && this._attachedId) {
301
+ const session = this.sessions.get(this._attachedId);
302
+ if (session) {
303
+ session.write(filepath);
304
+ }
305
+ }
306
+ } catch (err) {
307
+ responses.push({ type: 'error', message: `File upload failed: ${err.message}` });
308
+ }
309
+ }
310
+
311
+ _sessionList() {
312
+ const list = [];
313
+ for (const [id, session] of this.sessions) {
314
+ list.push({
315
+ id,
316
+ cols: session.cols,
317
+ rows: session.rows,
318
+ attached: id === this._attachedId,
319
+ });
320
+ }
321
+ return list;
322
+ }
323
+
324
+ _broadcastSessions() {
325
+ this.emit('send', { type: 'sessions', sessions: this._sessionList() });
326
+ }
327
+
328
+ destroy() {
329
+ for (const [, session] of this.sessions) {
330
+ session.kill();
331
+ }
332
+ this.sessions.clear();
333
+ }
334
+ }
335
+
336
+ module.exports = { PtyManager };
@@ -0,0 +1,193 @@
1
+ 'use strict';
2
+
3
+ const nodeDataChannel = require('node-datachannel');
4
+ const EventEmitter = require('events');
5
+
6
+ const ICE_SERVERS = [
7
+ 'stun:stun.l.google.com:19302',
8
+ 'stun:stun1.l.google.com:19302',
9
+ ];
10
+
11
+ const HEARTBEAT_INTERVAL = 5000;
12
+ const HEARTBEAT_TIMEOUT = 15000;
13
+ const SEND_BUFFER_HIGH = 1024 * 1024; // 1MB — pause sending when buffered
14
+ const SEND_BUFFER_LOW = 256 * 1024; // 256KB — resume sending
15
+ const MAX_MESSAGE_SIZE = 64 * 1024; // 64KB — chunk large messages
16
+
17
+ class CompanionWebRTC extends EventEmitter {
18
+ constructor() {
19
+ super();
20
+ this._pc = null;
21
+ this._dc = null;
22
+ this._connected = false;
23
+ this._heartbeatTimer = null;
24
+ this._lastPong = 0;
25
+ this._sendSignaling = null; // set externally
26
+ }
27
+
28
+ get isActive() {
29
+ return this._connected && this._dc !== null;
30
+ }
31
+
32
+ /**
33
+ * Initialize peer connection and create offer.
34
+ * @param {Function} sendSignaling - function(msg) to send signaling messages via WS
35
+ */
36
+ async initiate(sendSignaling) {
37
+ this._sendSignaling = sendSignaling;
38
+
39
+ this._pc = new nodeDataChannel.PeerConnection('companion', {
40
+ iceServers: ICE_SERVERS.map(s => s),
41
+ });
42
+
43
+ this._pc.onLocalDescription((sdp, type) => {
44
+ this._sendSignaling({ type: 'webrtc_offer', sdp, sdpType: type });
45
+ });
46
+
47
+ this._pc.onLocalCandidate((candidate, mid) => {
48
+ this._sendSignaling({ type: 'webrtc_ice', candidate, mid });
49
+ });
50
+
51
+ this._pc.onStateChange((state) => {
52
+ this.emit('connectionState', state);
53
+ if (state === 'failed' || state === 'closed') {
54
+ this._onDisconnect();
55
+ }
56
+ });
57
+
58
+ this._pc.onGatheringStateChange((state) => {
59
+ this.emit('gatheringState', state);
60
+ });
61
+
62
+ // Create DataChannel
63
+ this._dc = this._pc.createDataChannel('ghostterm', {
64
+ ordered: true,
65
+ });
66
+
67
+ this._dc.onOpen(() => {
68
+ this._connected = true;
69
+ this._lastPong = Date.now();
70
+ this._startHeartbeat();
71
+ this.emit('open');
72
+ });
73
+
74
+ this._dc.onClosed(() => {
75
+ this._onDisconnect();
76
+ });
77
+
78
+ this._dc.onError((err) => {
79
+ this.emit('error', err);
80
+ });
81
+
82
+ this._dc.onMessage((data) => {
83
+ this._handleMessage(data);
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Handle incoming signaling message from the mobile side.
89
+ */
90
+ handleSignaling(msg) {
91
+ if (!this._pc) return;
92
+
93
+ if (msg.type === 'webrtc_answer') {
94
+ this._pc.setRemoteDescription(msg.sdp, msg.sdpType || 'answer');
95
+ } else if (msg.type === 'webrtc_ice') {
96
+ this._pc.addRemoteCandidate(msg.candidate, msg.mid || '0');
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Send data over the DataChannel with flow control.
102
+ * Returns false if buffer is full (backpressure).
103
+ */
104
+ send(data) {
105
+ if (!this._dc || !this._connected) return false;
106
+ try {
107
+ // Check backpressure — drop if buffer is overwhelmed
108
+ const buffered = typeof this._dc.bufferedAmount === 'function'
109
+ ? this._dc.bufferedAmount() : 0;
110
+ if (buffered > SEND_BUFFER_HIGH) {
111
+ return false;
112
+ }
113
+
114
+ const str = typeof data === 'string' ? data : JSON.stringify(data);
115
+ this._dc.sendMessage(str);
116
+ return true;
117
+ } catch (err) {
118
+ this.emit('error', err);
119
+ return false;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Close the connection.
125
+ */
126
+ close() {
127
+ this._stopHeartbeat();
128
+ this._connected = false;
129
+ if (this._dc) {
130
+ try { this._dc.close(); } catch {}
131
+ this._dc = null;
132
+ }
133
+ if (this._pc) {
134
+ try { this._pc.close(); } catch {}
135
+ this._pc = null;
136
+ }
137
+ }
138
+
139
+ _handleMessage(raw) {
140
+ // Handle heartbeat
141
+ if (raw === '__pong__') {
142
+ this._lastPong = Date.now();
143
+ return;
144
+ }
145
+ if (raw === '__ping__') {
146
+ this.send('__pong__');
147
+ return;
148
+ }
149
+
150
+ try {
151
+ const msg = JSON.parse(raw);
152
+ this.emit('message', msg);
153
+ } catch {
154
+ // Binary or non-JSON data
155
+ this.emit('data', raw);
156
+ }
157
+ }
158
+
159
+ _startHeartbeat() {
160
+ this._stopHeartbeat();
161
+ this._heartbeatTimer = setInterval(() => {
162
+ if (!this._connected) return;
163
+
164
+ // Check timeout
165
+ if (Date.now() - this._lastPong > HEARTBEAT_TIMEOUT) {
166
+ this.emit('timeout');
167
+ this._onDisconnect();
168
+ return;
169
+ }
170
+
171
+ // Send ping
172
+ try {
173
+ if (this._dc) this._dc.sendMessage('__ping__');
174
+ } catch {}
175
+ }, HEARTBEAT_INTERVAL);
176
+ }
177
+
178
+ _stopHeartbeat() {
179
+ if (this._heartbeatTimer) {
180
+ clearInterval(this._heartbeatTimer);
181
+ this._heartbeatTimer = null;
182
+ }
183
+ }
184
+
185
+ _onDisconnect() {
186
+ if (!this._connected) return;
187
+ this._connected = false;
188
+ this._stopHeartbeat();
189
+ this.emit('close');
190
+ }
191
+ }
192
+
193
+ module.exports = { CompanionWebRTC };
package/package.json CHANGED
@@ -1,31 +1,35 @@
1
1
  {
2
2
  "name": "ghostterm",
3
- "version": "1.3.0",
4
- "description": "Mobile terminal for Claude Codecontrol your PC from your phone",
3
+ "version": "2.0.0",
4
+ "description": "Control your PC terminal from your phonedirect P2P, no server in between",
5
5
  "bin": {
6
- "ghostterm": "bin/ghostterm.js"
7
- },
8
- "scripts": {
9
- "start": "node bin/ghostterm.js"
6
+ "ghostterm": "bin/ghostterm-p2p.js"
10
7
  },
8
+ "files": [
9
+ "bin/",
10
+ "lib/",
11
+ "README.md",
12
+ "CHANGELOG.md"
13
+ ],
11
14
  "dependencies": {
15
+ "node-datachannel": "^0.12.0",
12
16
  "node-pty": "^1.0.0",
13
- "ws": "^8.18.0"
17
+ "ws": "^8.0.0",
18
+ "qrcode-terminal": "^0.12.0"
19
+ },
20
+ "engines": {
21
+ "node": ">=18.0.0"
14
22
  },
15
- "keywords": [
16
- "claude-code",
17
- "terminal",
18
- "mobile",
19
- "remote",
20
- "ghostterm"
21
- ],
22
- "author": "GhostTerm",
23
23
  "license": "MIT",
24
24
  "repository": {
25
25
  "type": "git",
26
- "url": "git+https://github.com/user/ghostterm.git"
26
+ "url": "git+https://github.com/anthropic/ghostterm-p2p.git"
27
27
  },
28
- "engines": {
29
- "node": ">=18"
30
- }
28
+ "keywords": [
29
+ "terminal",
30
+ "remote",
31
+ "p2p",
32
+ "webrtc",
33
+ "mobile"
34
+ ]
31
35
  }