upfynai-code 2.8.5 → 2.9.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.
package/README.md CHANGED
@@ -2,35 +2,35 @@
2
2
 
3
3
  > Unified AI coding interface — access AI chat, terminal, file explorer, git, and visual canvas from any browser.
4
4
 
5
- **Upfyn-Code** connects your local development machine to a browser-based UI through a secure WebSocket relay. Write code with AI assistance, run shell commands, manage files, handle git operations, and plan on a visual canvas — all from any device, anywhere.
5
+ **Upfyn-Code** connects your local development machine to a browser-based UI through a secure, encrypted connection. Write code with AI assistance, run shell commands, manage files, handle git operations, and plan on a visual canvas — all from any device, anywhere.
6
6
 
7
- Your source code **never leaves your machine**. All file operations, terminal commands, and git actions execute locally through the relay connection.
7
+ Your source code **never leaves your machine**. All file operations, terminal commands, and git actions execute locally.
8
8
 
9
9
  ## Install
10
10
 
11
11
  ```bash
12
- npm install -g upfyn-code
12
+ npm install -g upfynai-code
13
13
  ```
14
14
 
15
15
  Or run directly with npx:
16
16
 
17
17
  ```bash
18
- npx upfyn-code
18
+ npx upfynai-code
19
19
  ```
20
20
 
21
- **Requirements:** Node.js 18 or later. Works on Windows, macOS, and Linux.
21
+ **Requirements:** Node.js 22 or later. Works on Windows, macOS, and Linux.
22
22
 
23
23
  ## Quick Start
24
24
 
25
25
  ```bash
26
26
  # 1. Authenticate with your Upfyn account
27
- upfyn-code login
27
+ uc login
28
28
 
29
29
  # 2. Connect your machine to the web interface
30
- upfyn-code connect
30
+ uc connect
31
31
 
32
32
  # 3. Open the web UI (opens automatically)
33
- upfyn-code
33
+ uc start
34
34
  ```
35
35
 
36
36
  Once connected, open [cli.upfyn.com](https://cli.upfyn.com) on any device to access your full development environment.
@@ -39,45 +39,22 @@ Once connected, open [cli.upfyn.com](https://cli.upfyn.com) on any device to acc
39
39
 
40
40
  | Command | Description |
41
41
  |---------|-------------|
42
- | `upfyn-code` | Open the hosted web interface (default) |
43
- | `upfyn-code --local` | Start a local server instead of the hosted app |
44
- | `upfyn-code login` | Authenticate with your Upfyn account |
45
- | `upfyn-code logout` | Clear saved credentials |
46
- | `upfyn-code status` | Show current auth status and configuration |
47
- | `upfyn-code connect` | Connect your local machine to the remote server |
48
- | `upfyn-code mcp` | Show MCP integration config for Claude / Cursor |
42
+ | `uc` | Launch Claude Code with Upfyn web UI in background |
43
+ | `uc start` | Start the web UI server |
44
+ | `uc connect` | Connect your local machine to the cloud interface |
45
+ | `uc login` | Authenticate with your Upfyn account |
46
+ | `uc logout` | Clear saved credentials |
47
+ | `uc status` | Show current configuration |
48
+ | `uc config` | View or set configuration |
49
+ | `uc help` | Show all available commands |
49
50
 
50
51
  ### Connect Options
51
52
 
52
53
  ```bash
53
- upfyn-code connect --server <url> --key <token>
54
+ uc connect --key <token>
54
55
  ```
55
56
 
56
- - `--server <url>` — Server URL to connect to
57
- - `--key <token>` — Relay token (get from the web UI after signing in)
58
-
59
- ### MCP Integration
60
-
61
- ```bash
62
- upfyn-code mcp --server <url> --key <token>
63
- ```
64
-
65
- Outputs the MCP (Model Context Protocol) configuration JSON for use with Claude Desktop, Cursor, or other MCP-compatible tools.
66
-
67
- **Example output:**
68
-
69
- ```json
70
- {
71
- "mcpServers": {
72
- "upfyn-code": {
73
- "url": "https://your-server.com/mcp",
74
- "headers": {
75
- "Authorization": "Bearer rt_xxxxx"
76
- }
77
- }
78
- }
79
- }
80
- ```
57
+ - `--key <token>` — Your connection token (get from the web UI after signing in)
81
58
 
82
59
  ## Features
83
60
 
@@ -102,55 +79,18 @@ Connect external tools and services via the Model Context Protocol. Use Upfyn-Co
102
79
  ## How It Works
103
80
 
104
81
  1. **Install** the CLI on your development machine
105
- 2. **Authenticate** with `upfyn-code login`
106
- 3. **Connect** with `upfyn-code connect` — this creates a secure WebSocket tunnel
82
+ 2. **Authenticate** with `uc login`
83
+ 3. **Connect** with `uc connect` — creates a secure, encrypted connection
107
84
  4. **Open** [cli.upfyn.com](https://cli.upfyn.com) on any browser (desktop, tablet, or mobile)
108
85
 
109
- The CLI bridges your local filesystem, terminal, and git to the web interface. No ports are exposed and no firewall configuration is needed — the connection works through a secure relay.
110
-
111
- ```
112
- ┌─────────────┐ WebSocket ┌──────────────┐ Browser ┌──────────────┐
113
- │ Your Local │ ──────────────▶ │ Upfyn Relay │ ◀────────────── │ Web UI at │
114
- │ Machine │ (encrypted) │ Server │ │ cli.upfyn.com│
115
- └─────────────┘ └──────────────┘ └──────────────┘
116
- ```
86
+ The CLI connects your local filesystem, terminal, and git to the web interface. No ports are exposed and no firewall configuration is needed.
117
87
 
118
88
  ## Security
119
89
 
120
- - All communication is encrypted with TLS
121
- - Source code is relayed in real-time and **never stored** on servers
122
- - Authentication uses JWT tokens with httpOnly cookies
123
- - The relay connection requires both a valid session and a connection token
124
- - Self-hosting is supported for full data sovereignty
125
-
126
- ## Self-Hosting
127
-
128
- Upfyn-Code is open source under the GPL-3.0 license. You can deploy the entire stack on your own infrastructure:
129
-
130
- ```bash
131
- git clone https://github.com/AnitChaudhry/UpfynAI-Code.git
132
- cd UpfynAI-Code
133
-
134
- # Install dependencies
135
- npm install
136
-
137
- # Start the backend
138
- cd backend && npm start
139
-
140
- # Start the frontend
141
- cd frontend && npm run dev
142
- ```
143
-
144
- See the [repository](https://github.com/AnitChaudhry/UpfynAI-Code) for full deployment instructions.
145
-
146
- ## Configuration
147
-
148
- Credentials and config are stored locally at:
149
-
150
- | Platform | Path |
151
- |----------|------|
152
- | macOS / Linux | `~/.config/upfyn-code/` |
153
- | Windows | `%APPDATA%\upfyn-code\` |
90
+ - All communication is encrypted end-to-end
91
+ - Source code is **never stored** on servers
92
+ - Secure authentication with automatic session management
93
+ - Each user's environment is fully isolated
154
94
 
155
95
  ## Links
156
96
 
@@ -163,4 +103,4 @@ Credentials and config are stored locally at:
163
103
 
164
104
  GPL-3.0 — see [LICENSE](https://github.com/AnitChaudhry/UpfynAI-Code/blob/main/LICENSE) for details.
165
105
 
166
- Built by [Thinqmesh Technologies](https://cli.upfyn.com).
106
+ Built by [Upfynai-Code](https://cli.upfyn.com).
package/bin/cli.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  import { Command } from 'commander';
4
4
  import { readFileSync } from 'fs';
@@ -55,7 +55,7 @@ program
55
55
  .command('connect')
56
56
  .description('Connect local machine to the remote server (bridges Claude Code, shell, files, git)')
57
57
  .option('--server <url>', 'Server URL to connect to')
58
- .option('--key <token>', 'Relay token (rt_xxx) — get from the web UI')
58
+ .option('--key <token>', 'Connection token — get from Settings > Keys & Connect')
59
59
  .action(async (options) => {
60
60
  await connect(options);
61
61
  });
@@ -5,12 +5,14 @@
5
5
  * These are STREAMING actions — they use ctx.stream() to send chunks
6
6
  * and return a Promise that resolves when the process completes.
7
7
  *
8
- * Two modes:
9
- * - stream-json (connect.js) — full structured streaming with session tracking
8
+ * Three modes:
9
+ * - sdk (connect.js default) — full SDK streaming with rich messages (tool_use, text, result)
10
10
  * - simple --print (relay-client.js) — basic stdout streaming
11
11
  *
12
12
  * The caller chooses the mode via ctx.streamMode ('structured' | 'simple').
13
+ * 'structured' now maps to SDK mode for rich message forwarding.
13
14
  */
15
+ import { query } from '@anthropic-ai/claude-agent-sdk';
14
16
  import { spawn } from 'child_process';
15
17
  import os from 'os';
16
18
 
@@ -20,137 +22,183 @@ export default {
20
22
  'claude-query': async (params, ctx) => {
21
23
  const { command, options } = params;
22
24
  const mode = ctx.streamMode || 'structured';
23
- const resolveBinary = ctx.resolveBinary || ((name) => name);
24
25
 
25
26
  if (mode === 'simple') {
27
+ const resolveBinary = ctx.resolveBinary || ((name) => name);
26
28
  return runSimpleClaudeQuery(command, options, ctx, resolveBinary);
27
29
  }
28
- return runStructuredClaudeQuery(command, options, ctx, resolveBinary);
30
+ return runSDKClaudeQuery(command, options, ctx);
29
31
  },
30
32
 
31
33
  'claude-task-query': async (params, ctx) => {
32
34
  const { command, options } = params;
33
- const resolveBinary = ctx.resolveBinary || ((name) => name);
34
- return runClaudeTaskQuery(command, options, ctx, resolveBinary);
35
+ return runSDKClaudeTaskQuery(command, options, ctx);
35
36
  },
36
37
  },
37
38
  };
38
39
 
39
40
  /**
40
- * Structured streaming mode (stream-json).
41
- * Parses NDJSON events, computes text deltas, tracks session ID.
41
+ * Build SDK options from relay command options.
42
+ * Maps the relay format to SDK Options type.
42
43
  */
43
- function runStructuredClaudeQuery(command, options, ctx, resolveBinary) {
44
- return new Promise((resolve) => {
45
- const args = ['-p', '--output-format', 'stream-json', '--include-partial-messages'];
46
- if (options?.sessionId) args.push('--continue', options.sessionId);
44
+ function buildSDKOptions(options = {}) {
45
+ const sdkOptions = {
46
+ cwd: options.projectPath || os.homedir(),
47
+ systemPrompt: { type: 'preset', preset: 'claude_code' },
48
+ settingSources: ['project', 'user', 'local'],
49
+ };
47
50
 
48
- const proc = spawn(resolveBinary('claude'), [...args, command || ''], {
49
- shell: true,
50
- cwd: options?.projectPath || os.homedir(),
51
- env: process.env,
52
- });
51
+ if (options.sessionId) {
52
+ sdkOptions.resume = options.sessionId;
53
+ }
53
54
 
54
- if (ctx.trackProcess) ctx.trackProcess(ctx.requestId, { proc, action: 'claude-query' });
55
+ if (options.model) {
56
+ sdkOptions.model = options.model;
57
+ }
55
58
 
56
- let stdoutBuffer = '';
57
- let capturedSessionId = null;
58
- let lastTextLength = 0;
59
+ if (options.maxBudgetUsd) {
60
+ sdkOptions.maxBudgetUsd = options.maxBudgetUsd;
61
+ }
59
62
 
60
- proc.stdout.on('data', (chunk) => {
61
- stdoutBuffer += chunk.toString();
62
- const lines = stdoutBuffer.split('\n');
63
- stdoutBuffer = lines.pop();
64
-
65
- for (const line of lines) {
66
- if (!line.trim()) continue;
67
- try {
68
- const evt = JSON.parse(line);
69
-
70
- if (evt.type === 'system' && evt.subtype === 'init') {
71
- if (evt.session_id) capturedSessionId = evt.session_id;
72
- lastTextLength = 0;
73
- ctx.stream({ type: 'claude-system', sessionId: evt.session_id, model: evt.model, cwd: evt.cwd });
74
- } else if (evt.type === 'assistant' && evt.message?.content?.length) {
75
- const fullText = evt.message.content[0].text || '';
76
- const delta = fullText.slice(lastTextLength);
77
- lastTextLength = fullText.length;
78
- if (delta) {
79
- ctx.stream({ type: 'claude-response', content: delta });
80
- }
81
- } else if (evt.type === 'result') {
82
- lastTextLength = 0;
83
- ctx.stream({ type: 'claude-result', sessionId: capturedSessionId, subtype: evt.subtype });
84
- }
85
- } catch {
86
- if (line.trim() && !line.startsWith('%') && !line.includes('claude query')) {
87
- ctx.stream({ type: 'claude-response', content: line });
88
- }
89
- }
90
- }
91
- });
63
+ if (options.permissionMode && options.permissionMode !== 'default') {
64
+ sdkOptions.permissionMode = options.permissionMode;
65
+ }
92
66
 
93
- proc.stderr.on('data', (chunk) => {
94
- ctx.stream({ type: 'claude-error', content: chunk.toString() });
95
- });
67
+ if (options.allowedTools && options.allowedTools.length > 0) {
68
+ sdkOptions.allowedTools = options.allowedTools;
69
+ }
96
70
 
97
- proc.on('close', (code) => {
98
- if (ctx.untrackProcess) ctx.untrackProcess(ctx.requestId);
99
- resolve({ exitCode: code, sessionId: capturedSessionId });
100
- });
71
+ if (options.disallowedTools && options.disallowedTools.length > 0) {
72
+ sdkOptions.disallowedTools = options.disallowedTools;
73
+ }
101
74
 
102
- proc.on('error', () => {
103
- if (ctx.untrackProcess) ctx.untrackProcess(ctx.requestId);
104
- resolve({ exitCode: 1, sessionId: capturedSessionId });
105
- });
106
- });
75
+ if (options.maxTurns) {
76
+ sdkOptions.maxTurns = options.maxTurns;
77
+ }
78
+
79
+ // Pass through tools preset to make all Claude Code tools available
80
+ sdkOptions.tools = { type: 'preset', preset: 'claude_code' };
81
+
82
+ return sdkOptions;
107
83
  }
108
84
 
109
85
  /**
110
- * Simple streaming mode (--print).
111
- * Just pipes stdout/stderr chunks.
86
+ * SDK-based structured streaming mode.
87
+ * Forwards rich SDK messages (tool_use, tool_result, text, system, result)
88
+ * through ctx.stream() for the relay to forward to the frontend.
112
89
  */
113
- function runSimpleClaudeQuery(command, options, ctx, resolveBinary) {
114
- return new Promise((resolve) => {
115
- const args = ['--print'];
116
- if (options?.sessionId) args.push('--continue', options.sessionId);
117
-
118
- const proc = spawn(resolveBinary('claude'), [...args, command || ''], {
119
- shell: true,
120
- cwd: options?.projectPath || os.homedir(),
121
- env: process.env,
90
+ async function runSDKClaudeQuery(command, options, ctx) {
91
+ const sdkOptions = buildSDKOptions(options);
92
+
93
+ // Set stream-close timeout for interactive tools
94
+ const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
95
+ process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
96
+
97
+ let queryInstance;
98
+ try {
99
+ queryInstance = query({
100
+ prompt: command || '',
101
+ options: sdkOptions,
122
102
  });
103
+ } finally {
104
+ // Restore immediately — Query constructor already captured the value
105
+ if (prevStreamTimeout !== undefined) {
106
+ process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = prevStreamTimeout;
107
+ } else {
108
+ delete process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
109
+ }
110
+ }
111
+
112
+ // Track the query instance for abort support (uses .interrupt() instead of .kill())
113
+ if (ctx.trackProcess) {
114
+ ctx.trackProcess(ctx.requestId, { instance: queryInstance, action: 'claude-query' });
115
+ }
116
+
117
+ let capturedSessionId = null;
118
+
119
+ try {
120
+ for await (const message of queryInstance) {
121
+ // Capture session ID from first message that has one
122
+ if (message.session_id && !capturedSessionId) {
123
+ capturedSessionId = message.session_id;
124
+ }
123
125
 
124
- if (ctx.trackProcess) ctx.trackProcess(ctx.requestId, { proc, action: 'claude-query' });
126
+ // Forward the full SDK message the backend routeViaRelay() will
127
+ // detect 'claude-sdk-message' type and forward directly to frontend,
128
+ // matching the same format as queryClaudeSDK() in claude-sdk.js
129
+ ctx.stream({
130
+ type: 'claude-sdk-message',
131
+ data: message,
132
+ sessionId: capturedSessionId,
133
+ });
134
+ }
135
+
136
+ return { exitCode: 0, sessionId: capturedSessionId };
137
+ } catch (error) {
138
+ ctx.stream({ type: 'claude-error', content: error.message || 'SDK query failed' });
139
+ return { exitCode: 1, sessionId: capturedSessionId };
140
+ } finally {
141
+ if (ctx.untrackProcess) ctx.untrackProcess(ctx.requestId);
142
+ }
143
+ }
125
144
 
126
- proc.stdout.on('data', (chunk) => {
127
- ctx.stream({ type: 'claude-response', content: chunk.toString() });
128
- });
145
+ /**
146
+ * SDK-based sub-agent for read-only research tasks.
147
+ * Restricts tools to read-only operations.
148
+ */
149
+ async function runSDKClaudeTaskQuery(command, options, ctx) {
150
+ const sdkOptions = buildSDKOptions(options);
129
151
 
130
- proc.stderr.on('data', (chunk) => {
131
- ctx.stream({ type: 'claude-error', content: chunk.toString() });
132
- });
152
+ // Restrict to read-only tools
153
+ sdkOptions.allowedTools = ['View', 'Glob', 'Grep', 'LS', 'Read'];
133
154
 
134
- proc.on('close', (code) => {
135
- if (ctx.untrackProcess) ctx.untrackProcess(ctx.requestId);
136
- resolve({ exitCode: code });
155
+ let queryInstance;
156
+ try {
157
+ queryInstance = query({
158
+ prompt: command || '',
159
+ options: sdkOptions,
137
160
  });
161
+ } catch (error) {
162
+ ctx.stream({ type: 'claude-error', content: error.message || 'SDK task query failed' });
163
+ return { exitCode: 1 };
164
+ }
138
165
 
139
- proc.on('error', () => {
140
- if (ctx.untrackProcess) ctx.untrackProcess(ctx.requestId);
141
- resolve({ exitCode: 1 });
142
- });
143
- });
166
+ if (ctx.trackProcess) {
167
+ ctx.trackProcess(ctx.requestId, { instance: queryInstance, action: 'claude-task-query' });
168
+ }
169
+
170
+ let capturedSessionId = null;
171
+
172
+ try {
173
+ for await (const message of queryInstance) {
174
+ if (message.session_id && !capturedSessionId) {
175
+ capturedSessionId = message.session_id;
176
+ }
177
+
178
+ ctx.stream({
179
+ type: 'claude-sdk-message',
180
+ data: message,
181
+ sessionId: capturedSessionId,
182
+ });
183
+ }
184
+
185
+ return { exitCode: 0, sessionId: capturedSessionId };
186
+ } catch (error) {
187
+ ctx.stream({ type: 'claude-error', content: error.message || 'SDK task query failed' });
188
+ return { exitCode: 1, sessionId: capturedSessionId };
189
+ } finally {
190
+ if (ctx.untrackProcess) ctx.untrackProcess(ctx.requestId);
191
+ }
144
192
  }
145
193
 
146
194
  /**
147
- * Sub-agent for read-only research tasks.
148
- * Uses --allowedTools to restrict to read-only operations.
195
+ * Simple streaming mode (--print).
196
+ * Just pipes stdout/stderr chunks. Kept for backward compat with relay-client.js.
149
197
  */
150
- function runClaudeTaskQuery(command, options, ctx, resolveBinary) {
198
+ function runSimpleClaudeQuery(command, options, ctx, resolveBinary) {
151
199
  return new Promise((resolve) => {
152
- const args = ['-p', '--output-format', 'stream-json', '--include-partial-messages'];
153
- args.push('--allowedTools', 'View,Glob,Grep,LS,Read');
200
+ const args = ['--print'];
201
+ if (options?.sessionId) args.push('--continue', options.sessionId);
154
202
 
155
203
  const proc = spawn(resolveBinary('claude'), [...args, command || ''], {
156
204
  shell: true,
@@ -158,26 +206,10 @@ function runClaudeTaskQuery(command, options, ctx, resolveBinary) {
158
206
  env: process.env,
159
207
  });
160
208
 
161
- if (ctx.trackProcess) ctx.trackProcess(ctx.requestId, { proc, action: 'claude-task-query' });
162
- let taskBuffer = '';
209
+ if (ctx.trackProcess) ctx.trackProcess(ctx.requestId, { proc, action: 'claude-query' });
163
210
 
164
211
  proc.stdout.on('data', (chunk) => {
165
- taskBuffer += chunk.toString();
166
- const lines = taskBuffer.split('\n');
167
- taskBuffer = lines.pop();
168
- for (const line of lines) {
169
- if (!line.trim()) continue;
170
- try {
171
- const evt = JSON.parse(line);
172
- if (evt.type === 'assistant' && evt.message?.content?.length) {
173
- ctx.stream({ type: 'claude-response', content: evt.message.content[0].text || '' });
174
- }
175
- } catch {
176
- if (line.trim()) {
177
- ctx.stream({ type: 'claude-response', content: line });
178
- }
179
- }
180
- }
212
+ ctx.stream({ type: 'claude-response', content: chunk.toString() });
181
213
  });
182
214
 
183
215
  proc.stderr.on('data', (chunk) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "upfynai-code",
3
- "version": "2.8.5",
3
+ "version": "2.9.0",
4
4
  "description": "Unified AI coding interface — access AI chat, terminal, file explorer, git, and visual canvas from any browser. Connect your local machine and code from anywhere.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -58,6 +58,7 @@
58
58
  "url": "https://github.com/AnitChaudhry/UpfynAI-Code/issues"
59
59
  },
60
60
  "dependencies": {
61
+ "@anthropic-ai/claude-agent-sdk": "^0.1.71",
61
62
  "chalk": "^5.3.0",
62
63
  "commander": "^12.1.0",
63
64
  "express": "^4.21.0",
@@ -67,6 +68,9 @@
67
68
  "prompts": "^2.4.2",
68
69
  "ws": "^8.18.0"
69
70
  },
71
+ "optionalDependencies": {
72
+ "node-pty": "^1.1.0-beta34"
73
+ },
70
74
  "engines": {
71
75
  "node": ">=18.0.0"
72
76
  },
package/src/connect.js CHANGED
@@ -11,6 +11,10 @@ import { getPersistentShell } from './persistent-shell.js';
11
11
  import { needsPermission, requestPermission, handlePermissionResponse } from './permissions.js';
12
12
  import { playConnectAnimation } from './animation.js';
13
13
 
14
+ // Optional node-pty for proper terminal emulation (graceful fallback to spawn)
15
+ let pty;
16
+ try { pty = (await import('node-pty')).default; } catch { pty = null; }
17
+
14
18
  // Resolve agents: dist/agents/ (npm package) or ../../shared/agents/ (monorepo)
15
19
  const __connectDir = dirname(fileURLToPath(import.meta.url));
16
20
  const _agentsPath = existsSync(join(__connectDir, '../dist/agents/index.js'))
@@ -26,15 +30,17 @@ const activeShellSessions = new Map();
26
30
 
27
31
  /**
28
32
  * Start an interactive shell session on the local machine, relayed to the browser.
29
- * Spawns a PTY-like process and streams I/O via WebSocket.
33
+ * Uses node-pty for proper terminal emulation when available, falls back to spawn.
30
34
  */
31
35
  function handleShellSessionStart(data, ws) {
32
36
  const shellSessionId = data.requestId;
33
37
  const projectPath = data.projectPath || process.cwd();
34
38
  const isWin = process.platform === 'win32';
35
39
  const shellType = data.shellType;
40
+ const cols = data.cols || 80;
41
+ const rows = data.rows || 24;
36
42
 
37
- console.log(chalk.cyan(` [relay] Starting shell session in ${projectPath}`));
43
+ console.log(chalk.cyan(` [relay] Starting shell session in ${projectPath}${pty ? ' (pty)' : ' (spawn)'}`));
38
44
 
39
45
  let shellCmd, shellArgs;
40
46
  const provider = data.provider || 'claude';
@@ -42,8 +48,8 @@ function handleShellSessionStart(data, ws) {
42
48
 
43
49
  function getInteractiveShell(projectDir) {
44
50
  if (isWin) {
45
- if (shellType === 'cmd') return { cmd: 'cmd.exe', args: ['/K', `cd /d "${projectDir}"`] };
46
- return { cmd: 'powershell.exe', args: ['-NoExit', '-Command', `Set-Location -LiteralPath '${projectDir.replace(/'/g, "''")}'`] };
51
+ if (shellType === 'cmd') return { cmd: 'cmd.exe', args: [] };
52
+ return { cmd: 'powershell.exe', args: ['-NoExit'] };
47
53
  }
48
54
  const sh = shellType || process.env.SHELL || 'bash';
49
55
  return { cmd: sh, args: ['--login'] };
@@ -88,40 +94,68 @@ function handleShellSessionStart(data, ws) {
88
94
 
89
95
  ws.send(JSON.stringify({ type: 'relay-shell-output', shellSessionId, data: '' }));
90
96
 
91
- const proc = spawn(shellCmd, shellArgs, {
92
- cwd: isPlainShell && !data.initialCommand ? projectPath : undefined,
93
- env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor', FORCE_COLOR: '3' },
94
- stdio: ['pipe', 'pipe', 'pipe'],
95
- });
97
+ // Use node-pty for proper terminal emulation (resize, isatty, line discipline)
98
+ if (pty) {
99
+ const proc = pty.spawn(shellCmd, shellArgs, {
100
+ name: 'xterm-256color',
101
+ cols,
102
+ rows,
103
+ cwd: projectPath,
104
+ env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor', FORCE_COLOR: '3' },
105
+ });
96
106
 
97
- activeShellSessions.set(shellSessionId, { proc, projectPath });
107
+ activeShellSessions.set(shellSessionId, { proc, projectPath, isPty: true });
98
108
 
99
- proc.stdout.on('data', (chunk) => {
100
- if (ws.readyState === WebSocket.OPEN) {
101
- ws.send(JSON.stringify({ type: 'relay-shell-output', shellSessionId, data: chunk.toString() }));
102
- }
103
- });
109
+ proc.onData((chunk) => {
110
+ if (ws.readyState === WebSocket.OPEN) {
111
+ ws.send(JSON.stringify({ type: 'relay-shell-output', shellSessionId, data: chunk }));
112
+ }
113
+ });
104
114
 
105
- proc.stderr.on('data', (chunk) => {
106
- if (ws.readyState === WebSocket.OPEN) {
107
- ws.send(JSON.stringify({ type: 'relay-shell-output', shellSessionId, data: chunk.toString() }));
108
- }
109
- });
115
+ proc.onExit(({ exitCode }) => {
116
+ activeShellSessions.delete(shellSessionId);
117
+ if (ws.readyState === WebSocket.OPEN) {
118
+ ws.send(JSON.stringify({ type: 'relay-shell-exited', shellSessionId, exitCode }));
119
+ }
120
+ console.log(chalk.dim(` [relay] Shell session ended (code ${exitCode})`));
121
+ });
122
+ } else {
123
+ // Fallback: spawn without PTY (text wrapping may be incorrect)
124
+ const proc = spawn(shellCmd, shellArgs, {
125
+ cwd: projectPath,
126
+ env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor', FORCE_COLOR: '3', COLUMNS: String(cols), LINES: String(rows) },
127
+ stdio: ['pipe', 'pipe', 'pipe'],
128
+ });
110
129
 
111
- proc.on('close', (code) => {
112
- activeShellSessions.delete(shellSessionId);
113
- if (ws.readyState === WebSocket.OPEN) {
114
- ws.send(JSON.stringify({ type: 'relay-shell-exited', shellSessionId, exitCode: code }));
115
- }
116
- console.log(chalk.dim(` [relay] Shell session ended (code ${code})`));
117
- });
130
+ activeShellSessions.set(shellSessionId, { proc, projectPath, isPty: false });
118
131
 
119
- proc.on('error', (err) => {
120
- activeShellSessions.delete(shellSessionId);
121
- if (ws.readyState === WebSocket.OPEN) {
122
- ws.send(JSON.stringify({ type: 'relay-shell-output', shellSessionId, data: `\r\n\x1b[31mError: ${err.message}\x1b[0m\r\n` }));
123
- }
124
- });
132
+ proc.stdout.on('data', (chunk) => {
133
+ if (ws.readyState === WebSocket.OPEN) {
134
+ ws.send(JSON.stringify({ type: 'relay-shell-output', shellSessionId, data: chunk.toString() }));
135
+ }
136
+ });
137
+
138
+ proc.stderr.on('data', (chunk) => {
139
+ if (ws.readyState === WebSocket.OPEN) {
140
+ ws.send(JSON.stringify({ type: 'relay-shell-output', shellSessionId, data: chunk.toString() }));
141
+ }
142
+ });
143
+
144
+ proc.on('close', (code) => {
145
+ activeShellSessions.delete(shellSessionId);
146
+ if (ws.readyState === WebSocket.OPEN) {
147
+ ws.send(JSON.stringify({ type: 'relay-shell-exited', shellSessionId, exitCode: code }));
148
+ }
149
+ console.log(chalk.dim(` [relay] Shell session ended (code ${code})`));
150
+ });
151
+
152
+ proc.on('error', (err) => {
153
+ activeShellSessions.delete(shellSessionId);
154
+ if (ws.readyState === WebSocket.OPEN) {
155
+ ws.send(JSON.stringify({ type: 'relay-shell-output', shellSessionId, data: `\r\n\x1b[31mError: ${err.message}\x1b[0m\r\n` }));
156
+ }
157
+ });
158
+ }
125
159
  }
126
160
 
127
161
  /**
@@ -241,6 +275,9 @@ export async function connect(options = {}) {
241
275
  },
242
276
  });
243
277
 
278
+ // Respond to native WebSocket pings from server (Railway proxy keepalive)
279
+ ws.on('ping', () => { try { ws.pong(); } catch { /* ignore */ } });
280
+
244
281
  ws.on('open', () => {
245
282
  reconnectAttempts = 0;
246
283
  console.log(chalk.green(' Connected! Your local machine is now bridged to the server.'));
@@ -255,8 +292,11 @@ export async function connect(options = {}) {
255
292
  console.log(chalk.dim(` Default project: ${cwd}\n`));
256
293
 
257
294
  const heartbeat = setInterval(() => {
258
- if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ping' }));
259
- }, 30000);
295
+ if (ws.readyState === WebSocket.OPEN) {
296
+ ws.send(JSON.stringify({ type: 'ping' }));
297
+ try { ws.ping(); } catch { /* ignore */ }
298
+ }
299
+ }, 20000);
260
300
  ws.on('close', () => clearInterval(heartbeat));
261
301
  });
262
302
 
@@ -278,7 +318,14 @@ export async function connect(options = {}) {
278
318
  }
279
319
  if (data.type === 'relay-abort') {
280
320
  const entry = activeProcesses.get(data.requestId);
281
- if (entry?.proc) {
321
+ if (entry?.instance?.interrupt) {
322
+ // SDK query instance — use interrupt()
323
+ entry.instance.interrupt().catch(() => {});
324
+ activeProcesses.delete(data.requestId);
325
+ ws.send(JSON.stringify({ type: 'relay-complete', requestId: data.requestId, exitCode: -1, aborted: true }));
326
+ console.log(chalk.yellow(' [relay] SDK session interrupted by user'));
327
+ } else if (entry?.proc) {
328
+ // Legacy subprocess — use kill()
282
329
  entry.proc.kill('SIGTERM');
283
330
  activeProcesses.delete(data.requestId);
284
331
  ws.send(JSON.stringify({ type: 'relay-complete', requestId: data.requestId, exitCode: -1, aborted: true }));
@@ -292,20 +339,42 @@ export async function connect(options = {}) {
292
339
  }
293
340
  if (data.type === 'relay-shell-input') {
294
341
  const session = activeShellSessions.get(data.shellSessionId);
295
- if (session?.proc?.stdin?.writable) session.proc.stdin.write(data.data);
342
+ if (session) {
343
+ if (session.isPty) {
344
+ session.proc.write(data.data);
345
+ } else if (session.proc?.stdin?.writable) {
346
+ session.proc.stdin.write(data.data);
347
+ }
348
+ }
349
+ return;
350
+ }
351
+ if (data.type === 'relay-shell-resize') {
352
+ const session = activeShellSessions.get(data.shellSessionId);
353
+ if (session?.isPty && session.proc?.resize) {
354
+ try { session.proc.resize(data.cols || 80, data.rows || 24); } catch { /* ignore resize errors */ }
355
+ }
296
356
  return;
297
357
  }
298
- if (data.type === 'relay-shell-resize') return;
299
358
  if (data.type === 'relay-shell-kill') {
300
359
  const session = activeShellSessions.get(data.shellSessionId);
301
360
  if (session?.proc) {
302
- session.proc.kill('SIGTERM');
361
+ if (session.isPty) {
362
+ session.proc.kill();
363
+ } else {
364
+ session.proc.kill('SIGTERM');
365
+ }
303
366
  activeShellSessions.delete(data.shellSessionId);
304
367
  console.log(chalk.dim(' [relay] Shell session killed'));
305
368
  }
306
369
  return;
307
370
  }
308
371
  if (data.type === 'pong') return;
372
+ if (data.type === 'server-ping') {
373
+ if (ws.readyState === WebSocket.OPEN) {
374
+ ws.send(JSON.stringify({ type: 'server-pong' }));
375
+ }
376
+ return;
377
+ }
309
378
  if (data.type === 'error') {
310
379
  console.error(chalk.red(` Server error: ${data.error}`));
311
380
  return;