bortexcode 1.2.8 → 1.3.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.
Files changed (3) hide show
  1. package/README.md +51 -0
  2. package/bin/bortex.js +793 -2
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -47,6 +47,9 @@ export BORTEX_API_KEY=<YOUR_API_KEY>
47
47
  bortexcode
48
48
  bortexcode "explain this function"
49
49
  bortexcode --agent "refactor src/utils.js"
50
+ bortexcode remote-control
51
+ bortexcode remote-control --cloud
52
+ bortexcode --remote-control
50
53
  ```
51
54
 
52
55
  ## REPL Commands
@@ -63,9 +66,45 @@ Common commands:
63
66
  /help
64
67
  /llm-config show
65
68
  /llm-config sync
69
+ /remote-control [name]
70
+ /remote-control --lan
71
+ /remote-control --cloud
72
+ /rc
66
73
  /exit
67
74
  ```
68
75
 
76
+ ## Remote Control
77
+
78
+ Remote Control exposes the current Bortex Code process through a token-protected
79
+ browser UI. By default it binds to `127.0.0.1`.
80
+
81
+ ```bash
82
+ bortexcode remote-control --name "My Project"
83
+ bortexcode --remote-control "My Project"
84
+ ```
85
+
86
+ For phone access on the same LAN:
87
+
88
+ ```bash
89
+ bortexcode remote-control --remote-lan
90
+ ```
91
+
92
+ For remote access without opening an inbound port, use the bortex.site relay:
93
+
94
+ ```bash
95
+ bortexcode remote-control --cloud --name "My Project"
96
+ bortexcode --remote-cloud "My Project"
97
+ ```
98
+
99
+ Inside the REPL:
100
+
101
+ ```text
102
+ /remote-control My Project
103
+ /remote-control --lan
104
+ /remote-control --cloud
105
+ /remote-control stop
106
+ ```
107
+
69
108
  ## Options
70
109
 
71
110
  ```text
@@ -84,6 +123,15 @@ Common commands:
84
123
  --check-update Check for updates now
85
124
  --no-update-check
86
125
  Disable startup update check
126
+ --remote-control, --rc [name]
127
+ Enable browser remote control
128
+ --remote-cloud, --cloud
129
+ Use bortex.site relay instead of opening a local port
130
+ --remote-relay <url>
131
+ Remote relay base URL
132
+ --remote-lan Bind remote control to 0.0.0.0 for LAN/mobile access
133
+ --remote-host <host>, --remote-port <port>
134
+ Remote control bind address
87
135
  -v, --version Show version
88
136
  -h, --help Show help
89
137
  ```
@@ -105,4 +153,7 @@ BORTEX_URL
105
153
  BORTEX_API_KEY
106
154
  BORTEX_CLI_ICONS=1
107
155
  BORTEX_NO_UPDATE_CHECK=1
156
+ BORTEX_REMOTE_HOST
157
+ BORTEX_REMOTE_PORT
158
+ BORTEX_REMOTE_RELAY_URL
108
159
  ```
package/bin/bortex.js CHANGED
@@ -31,6 +31,13 @@ function parseArgs(argv) {
31
31
  _modelExplicit: false,
32
32
  _apiUrlExplicit: false,
33
33
  _apiKeyExplicit: false,
34
+ remoteControl: false,
35
+ remoteControlServerMode: false,
36
+ remoteControlCloud: false,
37
+ remoteControlRelayUrl: process.env.BORTEX_REMOTE_RELAY_URL || '',
38
+ remoteControlName: '',
39
+ remoteControlHost: process.env.BORTEX_REMOTE_HOST || '127.0.0.1',
40
+ remoteControlPort: Number(process.env.BORTEX_REMOTE_PORT || 0) || 0,
34
41
  ux: {
35
42
  verbose: false,
36
43
  spinner: true,
@@ -42,6 +49,14 @@ function parseArgs(argv) {
42
49
  const rest = [];
43
50
  for (let i = 0; i < argv.length; i += 1) {
44
51
  const a = argv[i];
52
+ if (a === 'remote-control' || a === 'remote') {
53
+ opts.remoteControlServerMode = true;
54
+ continue;
55
+ }
56
+ if ((a === 'cloud' || a === 'relay') && opts.remoteControlServerMode) {
57
+ opts.remoteControlCloud = true;
58
+ continue;
59
+ }
45
60
  if (a === '--agent' || a === '-a') {
46
61
  opts.agent = true;
47
62
  continue;
@@ -128,6 +143,68 @@ function parseArgs(argv) {
128
143
  opts.forceUpdateCheck = true;
129
144
  continue;
130
145
  }
146
+ if (a === '--remote-control' || a === '--rc') {
147
+ opts.remoteControl = true;
148
+ if (argv[i + 1] && !String(argv[i + 1]).startsWith('-')) {
149
+ opts.remoteControlName = argv[i + 1];
150
+ i += 1;
151
+ }
152
+ continue;
153
+ }
154
+ if (a.startsWith('--remote-control=')) {
155
+ opts.remoteControl = true;
156
+ opts.remoteControlName = a.slice('--remote-control='.length);
157
+ continue;
158
+ }
159
+ if (a === '--remote-cloud' || a === '--cloud' || a === '--relay') {
160
+ opts.remoteControl = true;
161
+ opts.remoteControlCloud = true;
162
+ if (argv[i + 1] && !String(argv[i + 1]).startsWith('-')) {
163
+ opts.remoteControlName = argv[i + 1];
164
+ i += 1;
165
+ }
166
+ continue;
167
+ }
168
+ if ((a === '--remote-relay' || a === '--relay-url') && argv[i + 1]) {
169
+ opts.remoteControlRelayUrl = argv[i + 1];
170
+ i += 1;
171
+ continue;
172
+ }
173
+ if (a.startsWith('--remote-relay=')) {
174
+ opts.remoteControlRelayUrl = a.slice('--remote-relay='.length);
175
+ continue;
176
+ }
177
+ if (a.startsWith('--relay-url=')) {
178
+ opts.remoteControlRelayUrl = a.slice('--relay-url='.length);
179
+ continue;
180
+ }
181
+ if (a === '--remote-lan') {
182
+ opts.remoteControlHost = '0.0.0.0';
183
+ continue;
184
+ }
185
+ if ((a === '--remote-host' || a === '--rc-host') && argv[i + 1]) {
186
+ opts.remoteControlHost = argv[i + 1];
187
+ i += 1;
188
+ continue;
189
+ }
190
+ if (a.startsWith('--remote-host=')) {
191
+ opts.remoteControlHost = a.slice('--remote-host='.length);
192
+ continue;
193
+ }
194
+ if ((a === '--remote-port' || a === '--rc-port') && argv[i + 1]) {
195
+ opts.remoteControlPort = Number(argv[i + 1]) || 0;
196
+ i += 1;
197
+ continue;
198
+ }
199
+ if (a.startsWith('--remote-port=')) {
200
+ opts.remoteControlPort = Number(a.slice('--remote-port='.length)) || 0;
201
+ continue;
202
+ }
203
+ if ((a === '--name' || a === '--remote-name') && argv[i + 1]) {
204
+ opts.remoteControlName = argv[i + 1];
205
+ i += 1;
206
+ continue;
207
+ }
131
208
  if (a === '--login' || a === '--signin') {
132
209
  opts.login = true;
133
210
  continue;
@@ -147,6 +224,8 @@ function usage() {
147
224
  console.log('');
148
225
  console.log('Usage:');
149
226
  console.log(` ${cliName} [options] [prompt]`);
227
+ console.log(` ${cliName} remote-control [--name <title>] [--remote-lan]`);
228
+ console.log(` ${cliName} remote-control --cloud [--name <title>]`);
150
229
  console.log(` ${cliName} --api-key <apikey>`);
151
230
  console.log(` ${cliName} "write a python function"`);
152
231
  console.log('');
@@ -166,15 +245,25 @@ function usage() {
166
245
  console.log(' --check-update Check for updates now');
167
246
  console.log(' --no-update-check');
168
247
  console.log(' Disable startup update check');
248
+ console.log(' --remote-control, --rc [name]');
249
+ console.log(' Enable browser remote control for this session');
250
+ console.log(' --remote-cloud, --cloud');
251
+ console.log(' Use bortex.site relay instead of opening a local port');
252
+ console.log(' --remote-relay <url>');
253
+ console.log(' Remote relay base URL (default: current --url)');
254
+ console.log(' --remote-lan Bind remote control to 0.0.0.0 for LAN/mobile access');
255
+ console.log(' --remote-host <host>, --remote-port <port>');
256
+ console.log(' Remote control bind address');
169
257
  console.log(' -v, --version Show version');
170
258
  console.log(' -h, --help Show help');
171
259
  console.log('');
172
- console.log('REPL commands: /agent on|off, /status, /exit, /help, /llm-config');
260
+ console.log('REPL commands: /agent on|off, /status, /remote-control, /exit, /help, /llm-config');
173
261
  console.log('Local tools: /pwd, /cd, /ls, /tree, /read, /write, /append, /mkdir, /git, /ssh-status, /sys-status, /process-status, /port-status, /tool, /sh');
174
262
  console.log('');
175
263
  console.log('Environment:');
176
264
  console.log(' BORTEX_URL Server URL');
177
265
  console.log(' BORTEX_API_KEY API key');
266
+ console.log(' BORTEX_REMOTE_RELAY_URL Remote Control cloud relay URL');
178
267
  }
179
268
 
180
269
  function formatMs(ms) {
@@ -3874,6 +3963,9 @@ function printLocalHelp() {
3874
3963
  console.log(' /sys-status check system / runtime status');
3875
3964
  console.log(' /process-status check running processes');
3876
3965
  console.log(' /port-status check listening ports');
3966
+ console.log(' /remote-control [name] [--lan|--host <host>|--port <port>]');
3967
+ console.log(' /remote-control --cloud [name]');
3968
+ console.log(' /remote-control stop');
3877
3969
  console.log(' /diff [unstaged|staged|all]');
3878
3970
  console.log(' /stage <file>|--all');
3879
3971
  console.log(' /unstage <file>|--all');
@@ -4107,6 +4199,616 @@ async function runGitApplyInline(opts, mode) {
4107
4199
  }
4108
4200
  }
4109
4201
 
4202
+ function escapeHtml(value) {
4203
+ return String(value ?? '')
4204
+ .replace(/&/g, '&amp;')
4205
+ .replace(/</g, '&lt;')
4206
+ .replace(/>/g, '&gt;')
4207
+ .replace(/"/g, '&quot;')
4208
+ .replace(/'/g, '&#39;');
4209
+ }
4210
+
4211
+ function getRemoteLanAddresses(port, token) {
4212
+ const out = [];
4213
+ const nets = os.networkInterfaces();
4214
+ Object.values(nets).forEach((entries) => {
4215
+ (entries || []).forEach((entry) => {
4216
+ if (!entry || entry.internal || entry.family !== 'IPv4') return;
4217
+ out.push(`http://${entry.address}:${port}/?token=${encodeURIComponent(token)}`);
4218
+ });
4219
+ });
4220
+ return out;
4221
+ }
4222
+
4223
+ function sendJson(res, statusCode, payload) {
4224
+ const body = JSON.stringify(payload);
4225
+ res.writeHead(statusCode, {
4226
+ 'content-type': 'application/json; charset=utf-8',
4227
+ 'cache-control': 'no-store',
4228
+ 'content-length': Buffer.byteLength(body)
4229
+ });
4230
+ res.end(body);
4231
+ }
4232
+
4233
+ function readHttpBody(req, maxBytes = 1024 * 1024) {
4234
+ return new Promise((resolve, reject) => {
4235
+ let size = 0;
4236
+ const chunks = [];
4237
+ req.on('data', (chunk) => {
4238
+ size += chunk.length;
4239
+ if (size > maxBytes) {
4240
+ reject(new Error('request body too large'));
4241
+ req.destroy();
4242
+ return;
4243
+ }
4244
+ chunks.push(chunk);
4245
+ });
4246
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
4247
+ req.on('error', reject);
4248
+ });
4249
+ }
4250
+
4251
+ function buildRemoteControlHtml(state) {
4252
+ const title = escapeHtml(state.name || 'Bortex Remote Control');
4253
+ const token = escapeHtml(state.token);
4254
+ const cwd = escapeHtml(state.cwd || process.cwd());
4255
+ return `<!doctype html>
4256
+ <html lang="en">
4257
+ <head>
4258
+ <meta charset="utf-8">
4259
+ <meta name="viewport" content="width=device-width,initial-scale=1">
4260
+ <title>${title}</title>
4261
+ <style>
4262
+ :root { color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
4263
+ body { margin: 0; background: #0f1115; color: #f4f7fb; }
4264
+ main { max-width: 980px; margin: 0 auto; padding: 20px; }
4265
+ header { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; border-bottom: 1px solid #2a2f3a; padding-bottom: 14px; }
4266
+ h1 { font-size: 20px; margin: 0 0 6px; font-weight: 700; }
4267
+ .meta { color: #9aa4b2; font-size: 13px; overflow-wrap: anywhere; }
4268
+ .pill { border: 1px solid #334155; border-radius: 999px; padding: 6px 10px; color: #cbd5e1; font-size: 12px; white-space: nowrap; }
4269
+ #log { margin: 18px 0; background: #080a0f; border: 1px solid #242a36; border-radius: 8px; min-height: 45vh; max-height: 62vh; overflow: auto; padding: 12px; }
4270
+ .msg { border-bottom: 1px solid #151923; padding: 10px 0; white-space: pre-wrap; overflow-wrap: anywhere; }
4271
+ .role { color: #8ab4ff; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 4px; }
4272
+ .assistant .role { color: #78d7a4; }
4273
+ .system .role { color: #fbbf24; }
4274
+ textarea { width: 100%; min-height: 92px; resize: vertical; border-radius: 8px; border: 1px solid #334155; background: #10141d; color: #f8fafc; padding: 12px; font: 14px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; box-sizing: border-box; }
4275
+ .bar { display: flex; gap: 10px; margin-top: 10px; align-items: center; }
4276
+ button { border: 0; border-radius: 8px; background: #2563eb; color: white; font-weight: 650; padding: 10px 14px; cursor: pointer; }
4277
+ button:disabled { opacity: .55; cursor: not-allowed; }
4278
+ .hint { color: #94a3b8; font-size: 12px; }
4279
+ code { color: #bfdbfe; }
4280
+ </style>
4281
+ </head>
4282
+ <body>
4283
+ <main>
4284
+ <header>
4285
+ <div>
4286
+ <h1>${title}</h1>
4287
+ <div class="meta">cwd: <code>${cwd}</code></div>
4288
+ </div>
4289
+ <div id="status" class="pill">connecting</div>
4290
+ </header>
4291
+ <section id="log"></section>
4292
+ <form id="form">
4293
+ <textarea id="prompt" placeholder="Ask Bortex Code or run a slash command, e.g. /status, /tool-plan --run fix lint"></textarea>
4294
+ <div class="bar">
4295
+ <button id="send" type="submit">Send</button>
4296
+ <span class="hint">Token-protected local session. Keep this URL private.</span>
4297
+ </div>
4298
+ </form>
4299
+ </main>
4300
+ <script>
4301
+ const token = ${JSON.stringify(state.token)};
4302
+ const logEl = document.getElementById('log');
4303
+ const statusEl = document.getElementById('status');
4304
+ const promptEl = document.getElementById('prompt');
4305
+ const sendEl = document.getElementById('send');
4306
+ let lastCount = -1;
4307
+ function renderLog(items) {
4308
+ if (!Array.isArray(items) || items.length === lastCount) return;
4309
+ lastCount = items.length;
4310
+ logEl.innerHTML = items.map((item) => {
4311
+ const role = String(item.role || 'system');
4312
+ const text = String(item.text || '');
4313
+ return '<div class="msg ' + role.replace(/[^a-z0-9_-]/gi, '') + '"><div class="role">' + role + '</div><div>' +
4314
+ text.replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])) +
4315
+ '</div></div>';
4316
+ }).join('');
4317
+ logEl.scrollTop = logEl.scrollHeight;
4318
+ }
4319
+ async function refresh() {
4320
+ try {
4321
+ const r = await fetch('/api/status?token=' + encodeURIComponent(token), { cache: 'no-store' });
4322
+ const data = await r.json();
4323
+ statusEl.textContent = data.busy ? 'running' : 'connected';
4324
+ sendEl.disabled = !!data.busy;
4325
+ renderLog(data.log || []);
4326
+ } catch (err) {
4327
+ statusEl.textContent = 'disconnected';
4328
+ }
4329
+ }
4330
+ document.getElementById('form').addEventListener('submit', async (event) => {
4331
+ event.preventDefault();
4332
+ const text = promptEl.value.trim();
4333
+ if (!text) return;
4334
+ sendEl.disabled = true;
4335
+ await fetch('/api/prompt?token=' + encodeURIComponent(token), {
4336
+ method: 'POST',
4337
+ headers: { 'content-type': 'application/json' },
4338
+ body: JSON.stringify({ text })
4339
+ }).catch(() => {});
4340
+ promptEl.value = '';
4341
+ await refresh();
4342
+ });
4343
+ promptEl.addEventListener('keydown', (event) => {
4344
+ if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
4345
+ document.getElementById('form').requestSubmit();
4346
+ }
4347
+ });
4348
+ refresh();
4349
+ setInterval(refresh, 1200);
4350
+ </script>
4351
+ </body>
4352
+ </html>`;
4353
+ }
4354
+
4355
+ async function captureRemoteControlOutput(fn) {
4356
+ let stdout = '';
4357
+ let stderr = '';
4358
+ const originalStdoutWrite = process.stdout.write;
4359
+ const originalStderrWrite = process.stderr.write;
4360
+ process.stdout.write = function patchedStdoutWrite(chunk, encoding, cb) {
4361
+ stdout += Buffer.isBuffer(chunk) ? chunk.toString(typeof encoding === 'string' ? encoding : 'utf8') : String(chunk ?? '');
4362
+ return originalStdoutWrite.apply(process.stdout, arguments);
4363
+ };
4364
+ process.stderr.write = function patchedStderrWrite(chunk, encoding, cb) {
4365
+ stderr += Buffer.isBuffer(chunk) ? chunk.toString(typeof encoding === 'string' ? encoding : 'utf8') : String(chunk ?? '');
4366
+ return originalStderrWrite.apply(process.stderr, arguments);
4367
+ };
4368
+ try {
4369
+ await fn();
4370
+ } finally {
4371
+ process.stdout.write = originalStdoutWrite;
4372
+ process.stderr.write = originalStderrWrite;
4373
+ }
4374
+ return { stdout, stderr };
4375
+ }
4376
+
4377
+ async function runRemoteControlPrompt(opts, text) {
4378
+ const line = String(text || '').trim();
4379
+ if (!line) return { ok: false, output: 'Empty prompt.' };
4380
+ pushCliHistory(opts, `[remote] ${line}`);
4381
+ try { saveCliWorkspaceState(opts); } catch (_err) {}
4382
+ const captured = await captureRemoteControlOutput(async () => {
4383
+ if (line === '/exit' || line === '/quit') {
4384
+ console.log('Remote clients cannot exit the local terminal process. Stop it locally with Ctrl+C.');
4385
+ return;
4386
+ }
4387
+ const localResult = await handleLocalCommand(opts, line);
4388
+ if (localResult.handled) return;
4389
+ const localIntent = classifyLocalPromptIntent(line);
4390
+ if (localIntent?.command) {
4391
+ const intentResult = await handleLocalCommand(opts, localIntent.command);
4392
+ if (intentResult.handled) return;
4393
+ }
4394
+ const naturalFileAction = await runNaturalLocalFileAction(opts, line);
4395
+ if (naturalFileAction.handled) return;
4396
+ if (opts.offline) {
4397
+ console.log('Offline mode: use slash commands and local tools from this remote session.');
4398
+ return;
4399
+ }
4400
+ const data = await askServer(opts, line);
4401
+ printResponse(opts, data);
4402
+ });
4403
+ const output = `${captured.stdout || ''}${captured.stderr || ''}`.trim();
4404
+ return { ok: true, output: output || '(no output)' };
4405
+ }
4406
+
4407
+ async function startRemoteControlServer(opts, overrides = {}) {
4408
+ if (opts.remoteControlSession?.server) {
4409
+ console.log(`Remote Control already active: ${opts.remoteControlSession.localUrl}`);
4410
+ return opts.remoteControlSession;
4411
+ }
4412
+
4413
+ const http = require('http');
4414
+ const crypto = require('crypto');
4415
+ const host = String(overrides.host || opts.remoteControlHost || '127.0.0.1').trim() || '127.0.0.1';
4416
+ const port = Math.max(0, Math.min(65535, Number(overrides.port ?? opts.remoteControlPort ?? 0) || 0));
4417
+ const token = crypto.randomBytes(24).toString('hex');
4418
+ const state = {
4419
+ id: crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`,
4420
+ name: getRemoteControlDisplayName(opts, overrides),
4421
+ host,
4422
+ port,
4423
+ token,
4424
+ cwd: opts.cwd,
4425
+ startedAt: Date.now(),
4426
+ busy: false,
4427
+ log: []
4428
+ };
4429
+ const appendLog = (role, text) => {
4430
+ state.log.push({ role, text: String(text || ''), ts: Date.now() });
4431
+ if (state.log.length > 200) state.log.splice(0, state.log.length - 200);
4432
+ };
4433
+ appendLog('system', `Remote Control started for ${state.cwd}`);
4434
+
4435
+ const isAuthorized = (req, urlObj) => {
4436
+ const header = String(req.headers['x-bortex-remote-token'] || '');
4437
+ const query = String(urlObj.searchParams.get('token') || '');
4438
+ return header === token || query === token;
4439
+ };
4440
+
4441
+ const server = http.createServer(async (req, res) => {
4442
+ let urlObj;
4443
+ try { urlObj = new URL(req.url, `http://${host === '0.0.0.0' ? '127.0.0.1' : host}`); } catch (_err) {
4444
+ return sendJson(res, 400, { ok: false, error: 'bad url' });
4445
+ }
4446
+ if (req.method === 'GET' && urlObj.pathname === '/') {
4447
+ if (!isAuthorized(req, urlObj)) {
4448
+ res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
4449
+ return res.end('Unauthorized: missing or invalid token.');
4450
+ }
4451
+ const body = buildRemoteControlHtml(state);
4452
+ res.writeHead(200, {
4453
+ 'content-type': 'text/html; charset=utf-8',
4454
+ 'cache-control': 'no-store',
4455
+ 'content-length': Buffer.byteLength(body)
4456
+ });
4457
+ return res.end(body);
4458
+ }
4459
+ if (!isAuthorized(req, urlObj)) return sendJson(res, 401, { ok: false, error: 'unauthorized' });
4460
+ if (req.method === 'GET' && urlObj.pathname === '/api/status') {
4461
+ return sendJson(res, 200, {
4462
+ ok: true,
4463
+ id: state.id,
4464
+ name: state.name,
4465
+ version: CLI_VERSION,
4466
+ cwd: opts.cwd,
4467
+ agent: !!opts.agent,
4468
+ busy: !!state.busy,
4469
+ startedAt: state.startedAt,
4470
+ log: state.log
4471
+ });
4472
+ }
4473
+ if (req.method === 'POST' && urlObj.pathname === '/api/prompt') {
4474
+ if (state.busy) return sendJson(res, 409, { ok: false, error: 'remote session busy' });
4475
+ state.busy = true;
4476
+ try {
4477
+ const raw = await readHttpBody(req);
4478
+ let payload;
4479
+ try { payload = JSON.parse(raw || '{}'); } catch (_err) { payload = { text: raw }; }
4480
+ const text = String(payload.text || payload.prompt || '').trim();
4481
+ if (!text) return sendJson(res, 400, { ok: false, error: 'empty prompt' });
4482
+ appendLog('user', text);
4483
+ const result = await runRemoteControlPrompt(opts, text);
4484
+ appendLog('assistant', result.output || '(no output)');
4485
+ return sendJson(res, result.ok ? 200 : 500, { ok: !!result.ok, output: result.output || '' });
4486
+ } catch (err) {
4487
+ appendLog('system', `Error: ${err.message}`);
4488
+ return sendJson(res, 500, { ok: false, error: err.message });
4489
+ } finally {
4490
+ state.busy = false;
4491
+ }
4492
+ }
4493
+ return sendJson(res, 404, { ok: false, error: 'not found' });
4494
+ });
4495
+
4496
+ await new Promise((resolve, reject) => {
4497
+ server.once('error', reject);
4498
+ server.listen(port, host, resolve);
4499
+ });
4500
+ const actualPort = server.address().port;
4501
+ state.port = actualPort;
4502
+ state.server = server;
4503
+ state.localUrl = `http://127.0.0.1:${actualPort}/?token=${encodeURIComponent(token)}`;
4504
+ state.lanUrls = getRemoteLanAddresses(actualPort, token);
4505
+ state.stop = () => new Promise((resolve) => server.close(() => resolve()));
4506
+ opts.remoteControlSession = state;
4507
+
4508
+ console.log(`Remote Control active: ${state.name}`);
4509
+ console.log(`Local URL: ${state.localUrl}`);
4510
+ if (host === '0.0.0.0' || host === '::') {
4511
+ if (state.lanUrls.length) {
4512
+ console.log('LAN URLs:');
4513
+ state.lanUrls.forEach((u) => console.log(` ${u}`));
4514
+ }
4515
+ console.log('Security: keep this tokenized URL private. Anyone with the URL can control this Bortex Code session.');
4516
+ } else {
4517
+ console.log('Mobile/LAN access: restart with --remote-lan or /remote-control --lan to bind on 0.0.0.0.');
4518
+ }
4519
+ return state;
4520
+ }
4521
+
4522
+ async function stopRemoteControlServer(opts) {
4523
+ const state = opts.remoteControlSession;
4524
+ if (!state?.server) {
4525
+ console.log('Remote Control is not active.');
4526
+ return false;
4527
+ }
4528
+ await state.stop();
4529
+ opts.remoteControlSession = null;
4530
+ console.log('Remote Control stopped.');
4531
+ return true;
4532
+ }
4533
+
4534
+ function getRemoteControlDisplayName(opts, overrides = {}) {
4535
+ return String(overrides.name || opts.remoteControlName || path.basename(opts.cwd || process.cwd()) || os.hostname() || 'Bortex Code').trim();
4536
+ }
4537
+
4538
+ function getRemoteControlStatus(opts) {
4539
+ if (opts.remoteControlCloudSession?.active) return 'cloud';
4540
+ if (opts.remoteControlSession?.server) return 'local';
4541
+ return 'off';
4542
+ }
4543
+
4544
+ function getRemoteControlRelayBase(opts, overrides = {}) {
4545
+ return String(overrides.relayUrl || opts.remoteControlRelayUrl || opts.url || 'https://bortex.site').replace(/\/+$/, '');
4546
+ }
4547
+
4548
+ function remoteControlSleep(ms) {
4549
+ return new Promise((resolve) => setTimeout(resolve, ms));
4550
+ }
4551
+
4552
+ function withRemoteControlQuery(rawUrl, baseUrl, params = {}) {
4553
+ const urlObj = new URL(rawUrl, baseUrl);
4554
+ Object.entries(params).forEach(([key, value]) => {
4555
+ if (value !== undefined && value !== null && value !== '') {
4556
+ urlObj.searchParams.set(key, String(value));
4557
+ }
4558
+ });
4559
+ return urlObj.toString();
4560
+ }
4561
+
4562
+ async function fetchRemoteControlJson(url, options = {}, timeoutMs = 30000) {
4563
+ const controller = new AbortController();
4564
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
4565
+ if (typeof timeoutId.unref === 'function') timeoutId.unref();
4566
+ try {
4567
+ const res = await fetch(url, {
4568
+ ...options,
4569
+ headers: {
4570
+ Accept: 'application/json',
4571
+ ...(options.headers || {})
4572
+ },
4573
+ signal: controller.signal
4574
+ });
4575
+ const text = await res.text();
4576
+ let data = null;
4577
+ if (text) {
4578
+ try { data = JSON.parse(text); } catch (_err) { data = null; }
4579
+ }
4580
+ if (!res.ok) {
4581
+ const detail = data?.error || data?.message || text || res.statusText || 'request failed';
4582
+ throw new Error(`HTTP ${res.status}: ${detail}`);
4583
+ }
4584
+ if (data?.ok === false) {
4585
+ throw new Error(data.error || data.message || 'request failed');
4586
+ }
4587
+ return data || {};
4588
+ } finally {
4589
+ clearTimeout(timeoutId);
4590
+ }
4591
+ }
4592
+
4593
+ async function postCloudRemoteHeartbeat(state) {
4594
+ const heartbeatUrl = withRemoteControlQuery(
4595
+ state.heartbeatUrl,
4596
+ state.baseUrl,
4597
+ { agentToken: state.agentToken }
4598
+ );
4599
+ await fetchRemoteControlJson(heartbeatUrl, {
4600
+ method: 'POST',
4601
+ headers: { 'Content-Type': 'application/json' },
4602
+ body: JSON.stringify({ cwd: state.cwd, version: CLI_VERSION })
4603
+ }, 12000);
4604
+ }
4605
+
4606
+ async function postCloudRemoteResult(state, commandId, result) {
4607
+ const resultUrl = withRemoteControlQuery(
4608
+ state.resultUrl,
4609
+ state.baseUrl,
4610
+ { agentToken: state.agentToken }
4611
+ );
4612
+ await fetchRemoteControlJson(resultUrl, {
4613
+ method: 'POST',
4614
+ headers: { 'Content-Type': 'application/json' },
4615
+ body: JSON.stringify({
4616
+ commandId,
4617
+ ok: !!result.ok,
4618
+ output: String(result.output || '')
4619
+ })
4620
+ }, 35000);
4621
+ }
4622
+
4623
+ async function pollCloudRemoteControlLoop(opts, state) {
4624
+ let lastHeartbeatAt = 0;
4625
+ while (state.active) {
4626
+ let delayMs = 1200;
4627
+ try {
4628
+ const pollUrl = withRemoteControlQuery(
4629
+ state.pollUrl,
4630
+ state.baseUrl,
4631
+ { agentToken: state.agentToken, after: state.lastCommandId || 0 }
4632
+ );
4633
+ const data = await fetchRemoteControlJson(pollUrl, { method: 'GET' }, 35000);
4634
+ const commands = Array.isArray(data.commands) ? data.commands : [];
4635
+ state.lastSeenAt = Date.now();
4636
+ state.errorCount = 0;
4637
+
4638
+ for (const command of commands) {
4639
+ if (!state.active) break;
4640
+ const commandId = Number(command?.id || 0);
4641
+ if (commandId > (state.lastCommandId || 0)) state.lastCommandId = commandId;
4642
+ const text = String(command?.text || command?.prompt || '').trim();
4643
+ if (!text) continue;
4644
+ state.busy = true;
4645
+ let result;
4646
+ try {
4647
+ result = await runRemoteControlPrompt(opts, text);
4648
+ } catch (err) {
4649
+ result = { ok: false, output: err.stack || err.message || String(err) };
4650
+ } finally {
4651
+ state.busy = false;
4652
+ }
4653
+ await postCloudRemoteResult(state, commandId, result);
4654
+ }
4655
+
4656
+ if (!commands.length && Date.now() - lastHeartbeatAt > 10000) {
4657
+ lastHeartbeatAt = Date.now();
4658
+ await postCloudRemoteHeartbeat(state);
4659
+ }
4660
+ if (commands.length) delayMs = 250;
4661
+ } catch (err) {
4662
+ if (state.active) {
4663
+ state.errorCount = (state.errorCount || 0) + 1;
4664
+ const now = Date.now();
4665
+ if (!state.lastErrorAt || now - state.lastErrorAt > 15000) {
4666
+ console.error(`Cloud Remote Control error: ${err.message || String(err)}`);
4667
+ state.lastErrorAt = now;
4668
+ }
4669
+ delayMs = Math.min(5000, 1000 + state.errorCount * 500);
4670
+ }
4671
+ } finally {
4672
+ state.busy = false;
4673
+ }
4674
+ await remoteControlSleep(delayMs);
4675
+ }
4676
+ }
4677
+
4678
+ async function startCloudRemoteControlSession(opts, overrides = {}) {
4679
+ if (opts.remoteControlCloudSession?.active) {
4680
+ console.log(`Cloud Remote Control already active: ${opts.remoteControlCloudSession.url}`);
4681
+ return opts.remoteControlCloudSession;
4682
+ }
4683
+
4684
+ const baseUrl = getRemoteControlRelayBase(opts, overrides);
4685
+ const name = getRemoteControlDisplayName(opts, overrides);
4686
+ const payload = {
4687
+ name,
4688
+ cwd: opts.cwd,
4689
+ version: CLI_VERSION,
4690
+ machine: os.hostname(),
4691
+ platform: process.platform,
4692
+ node: process.version,
4693
+ mode: opts.agent ? 'agent' : 'chat'
4694
+ };
4695
+ const data = await fetchRemoteControlJson(`${baseUrl}/api/bortex-code/remote/register`, {
4696
+ method: 'POST',
4697
+ headers: { 'Content-Type': 'application/json' },
4698
+ body: JSON.stringify(payload)
4699
+ }, 30000);
4700
+
4701
+ const sessionId = String(data.sessionId || '').trim();
4702
+ const agentToken = String(data.agentToken || '').trim();
4703
+ if (!sessionId || !agentToken) {
4704
+ throw new Error('Remote relay registration did not return a session token.');
4705
+ }
4706
+
4707
+ const state = {
4708
+ active: true,
4709
+ cloud: true,
4710
+ baseUrl,
4711
+ sessionId,
4712
+ agentToken,
4713
+ clientToken: data.clientToken || '',
4714
+ name,
4715
+ cwd: opts.cwd,
4716
+ url: data.url || `${baseUrl}/bortex-code/remote?session=${encodeURIComponent(sessionId)}`,
4717
+ pollUrl: data.pollUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/poll`,
4718
+ resultUrl: data.resultUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/result`,
4719
+ heartbeatUrl: data.heartbeatUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/heartbeat`,
4720
+ lastCommandId: 0,
4721
+ startedAt: Date.now(),
4722
+ busy: false,
4723
+ errorCount: 0
4724
+ };
4725
+ state.stop = async () => {
4726
+ state.active = false;
4727
+ };
4728
+ opts.remoteControlCloudSession = state;
4729
+ state.pollPromise = pollCloudRemoteControlLoop(opts, state).catch((err) => {
4730
+ if (state.active) console.error(`Cloud Remote Control stopped unexpectedly: ${err.message || String(err)}`);
4731
+ });
4732
+
4733
+ console.log(`Cloud Remote Control active: ${state.name}`);
4734
+ console.log(`URL: ${state.url}`);
4735
+ console.log('No inbound port required. Keep this process running.');
4736
+ console.log('Security: keep this tokenized URL private. Anyone with the URL can control this Bortex Code session.');
4737
+ return state;
4738
+ }
4739
+
4740
+ async function stopCloudRemoteControlSession(opts) {
4741
+ const state = opts.remoteControlCloudSession;
4742
+ if (!state?.active) {
4743
+ console.log('Cloud Remote Control is not active.');
4744
+ return false;
4745
+ }
4746
+ await state.stop();
4747
+ opts.remoteControlCloudSession = null;
4748
+ console.log('Cloud Remote Control stopped.');
4749
+ return true;
4750
+ }
4751
+
4752
+ function parseRemoteControlSlashArgs(rest = [], opts = {}) {
4753
+ const out = {
4754
+ name: '',
4755
+ host: opts.remoteControlHost || '127.0.0.1',
4756
+ port: Number(opts.remoteControlPort || 0) || 0,
4757
+ relayUrl: opts.remoteControlRelayUrl || '',
4758
+ cloud: false
4759
+ };
4760
+ const nameParts = [];
4761
+ for (let i = 0; i < rest.length; i += 1) {
4762
+ const a = String(rest[i] || '');
4763
+ if (a === 'stop' || a === 'off' || a === 'disconnect') {
4764
+ out.stop = true;
4765
+ continue;
4766
+ }
4767
+ if (a === '--lan' || a === '--remote-lan') {
4768
+ out.host = '0.0.0.0';
4769
+ continue;
4770
+ }
4771
+ if (a === 'cloud' || a === '--cloud' || a === '--remote-cloud' || a === '--relay') {
4772
+ out.cloud = true;
4773
+ continue;
4774
+ }
4775
+ if ((a === '--relay-url' || a === '--remote-relay') && rest[i + 1]) {
4776
+ out.relayUrl = String(rest[i + 1]);
4777
+ i += 1;
4778
+ continue;
4779
+ }
4780
+ if (a.startsWith('--relay-url=')) {
4781
+ out.relayUrl = a.slice('--relay-url='.length);
4782
+ continue;
4783
+ }
4784
+ if (a.startsWith('--remote-relay=')) {
4785
+ out.relayUrl = a.slice('--remote-relay='.length);
4786
+ continue;
4787
+ }
4788
+ if ((a === '--host' || a === '--remote-host') && rest[i + 1]) {
4789
+ out.host = String(rest[i + 1]);
4790
+ i += 1;
4791
+ continue;
4792
+ }
4793
+ if (a.startsWith('--host=')) {
4794
+ out.host = a.slice('--host='.length);
4795
+ continue;
4796
+ }
4797
+ if ((a === '--port' || a === '--remote-port') && rest[i + 1]) {
4798
+ out.port = Number(rest[i + 1]) || 0;
4799
+ i += 1;
4800
+ continue;
4801
+ }
4802
+ if (a.startsWith('--port=')) {
4803
+ out.port = Number(a.slice('--port='.length)) || 0;
4804
+ continue;
4805
+ }
4806
+ nameParts.push(a);
4807
+ }
4808
+ out.name = nameParts.join(' ').trim();
4809
+ return out;
4810
+ }
4811
+
4110
4812
  function printAgentLocalHelp() {
4111
4813
  console.log('Agent-local (planner CLI):');
4112
4814
  console.log(' /agent-local <goal> genera un piano operativo CLI');
@@ -5390,6 +6092,38 @@ async function handleLocalCommand(opts, line) {
5390
6092
  });
5391
6093
  return { handled: true };
5392
6094
  }
6095
+ if (lc === '/agent') {
6096
+ const v = String(rest[0] || '').toLowerCase();
6097
+ if (!['on', 'off', 'true', 'false', '1', '0'].includes(v)) {
6098
+ console.log('Uso: /agent on|off');
6099
+ return { handled: true };
6100
+ }
6101
+ opts.agent = v === 'on' || v === 'true' || v === '1';
6102
+ console.log(`Mode: ${opts.agent ? 'agent' : 'chat'}`);
6103
+ return { handled: true };
6104
+ }
6105
+ if (lc === '/status') {
6106
+ const todoItems = Array.isArray(opts.todo) ? opts.todo : [];
6107
+ const done = todoItems.filter((t) => t.done).length;
6108
+ const runPending = Array.isArray(opts.runState?.steps) ? opts.runState.steps.filter((s) => s.status === 'pending').length : 0;
6109
+ console.log(formatModeLine(opts));
6110
+ console.log(`Todo: ${done}/${todoItems.length} | Plan: ${opts.plan?.goal ? 'active' : 'none'} | Run: ${opts.runState?.goal ? `active (${runPending} pending)` : 'none'} | Remote: ${getRemoteControlStatus(opts)}`);
6111
+ return { handled: true };
6112
+ }
6113
+ if (lc === '/remote-control' || lc === '/remote' || lc === '/rc') {
6114
+ const parsed = parseRemoteControlSlashArgs(rest, opts);
6115
+ if (parsed.stop || opts.remoteControlSession?.server || opts.remoteControlCloudSession?.active) {
6116
+ if (opts.remoteControlSession?.server) await stopRemoteControlServer(opts);
6117
+ if (opts.remoteControlCloudSession?.active) await stopCloudRemoteControlSession(opts);
6118
+ return { handled: true };
6119
+ }
6120
+ if (parsed.cloud) {
6121
+ await startCloudRemoteControlSession(opts, parsed);
6122
+ return { handled: true };
6123
+ }
6124
+ await startRemoteControlServer(opts, parsed);
6125
+ return { handled: true };
6126
+ }
5393
6127
  if (lc === '/pwd') {
5394
6128
  console.log(opts.cwd);
5395
6129
  return { handled: true };
@@ -6551,6 +7285,10 @@ const SLASH_COMMANDS = [
6551
7285
  ['/status', 'Show session and workspace state'],
6552
7286
  ['/commands', 'Show this command menu'],
6553
7287
  ['/help', 'Show command help'],
7288
+ ['/remote-control [name]', 'Control this local session from a browser'],
7289
+ ['/remote-control --lan', 'Expose Remote Control on the local network'],
7290
+ ['/remote-control --cloud', 'Control this session through bortex.site relay'],
7291
+ ['/rc', 'Toggle Remote Control'],
6554
7292
  ['/llm-config show', 'Show cached LLM configuration'],
6555
7293
  ['/llm-config sync', 'Sync LLM configuration from Bortex'],
6556
7294
  ['/pwd', 'Show current working directory'],
@@ -6725,6 +7463,20 @@ async function runRepl(opts) {
6725
7463
  return lines.join('\n');
6726
7464
  };
6727
7465
  opts._askInput = async (prompt) => new Promise((resolve) => rl.question(prompt, resolve));
7466
+ if (opts.remoteControl) {
7467
+ if (opts.remoteControlCloud) {
7468
+ await startCloudRemoteControlSession(opts, {
7469
+ name: opts.remoteControlName,
7470
+ relayUrl: opts.remoteControlRelayUrl
7471
+ });
7472
+ } else {
7473
+ await startRemoteControlServer(opts, {
7474
+ name: opts.remoteControlName,
7475
+ host: opts.remoteControlHost,
7476
+ port: opts.remoteControlPort
7477
+ });
7478
+ }
7479
+ }
6728
7480
 
6729
7481
  const question = () => new Promise((resolve) => {
6730
7482
  const prompt = `bortex:${opts.agent ? 'agent' : 'chat'}> `;
@@ -6749,7 +7501,7 @@ async function runRepl(opts) {
6749
7501
  const done = todoItems.filter((t) => t.done).length;
6750
7502
  console.log(formatModeLine(opts));
6751
7503
  const runPending = Array.isArray(opts.runState?.steps) ? opts.runState.steps.filter((s) => s.status === 'pending').length : 0;
6752
- console.log(`Todo: ${done}/${todoItems.length} | Plan: ${opts.plan?.goal ? 'active' : 'none'} | Run: ${opts.runState?.goal ? `active (${runPending} pending)` : 'none'}`);
7504
+ console.log(`Todo: ${done}/${todoItems.length} | Plan: ${opts.plan?.goal ? 'active' : 'none'} | Run: ${opts.runState?.goal ? `active (${runPending} pending)` : 'none'} | Remote: ${getRemoteControlStatus(opts)}`);
6753
7505
  continue;
6754
7506
  }
6755
7507
  if (line === '/help') {
@@ -6796,6 +7548,12 @@ async function runRepl(opts) {
6796
7548
 
6797
7549
  rl.close();
6798
7550
  uninstallSlashMenu();
7551
+ if (opts.remoteControlSession?.server) {
7552
+ await stopRemoteControlServer(opts);
7553
+ }
7554
+ if (opts.remoteControlCloudSession?.active) {
7555
+ await stopCloudRemoteControlSession(opts);
7556
+ }
6799
7557
  try { saveCliWorkspaceState(opts); } catch (_err) { }
6800
7558
  delete opts._readMultiline;
6801
7559
  delete opts._askInput;
@@ -6846,6 +7604,39 @@ async function main() {
6846
7604
  opts.sessionReused = false;
6847
7605
  }
6848
7606
 
7607
+ if (opts.remoteControlServerMode) {
7608
+ if (opts.remoteControlCloud) {
7609
+ await startCloudRemoteControlSession(opts, {
7610
+ name: opts.remoteControlName,
7611
+ relayUrl: opts.remoteControlRelayUrl
7612
+ });
7613
+ } else {
7614
+ await startRemoteControlServer(opts, {
7615
+ name: opts.remoteControlName,
7616
+ host: opts.remoteControlHost,
7617
+ port: opts.remoteControlPort
7618
+ });
7619
+ }
7620
+ console.log('Server mode: keep this process running. Press Ctrl+C to stop Remote Control.');
7621
+ await new Promise((resolve) => {
7622
+ let stopping = false;
7623
+ const stop = async () => {
7624
+ if (stopping) return;
7625
+ stopping = true;
7626
+ if (opts.remoteControlSession?.server) {
7627
+ try { await stopRemoteControlServer(opts); } catch (_err) {}
7628
+ }
7629
+ if (opts.remoteControlCloudSession?.active) {
7630
+ try { await stopCloudRemoteControlSession(opts); } catch (_err) {}
7631
+ }
7632
+ resolve();
7633
+ };
7634
+ process.once('SIGINT', stop);
7635
+ process.once('SIGTERM', stop);
7636
+ });
7637
+ return;
7638
+ }
7639
+
6849
7640
  if (opts.prompt) {
6850
7641
  await runSinglePrompt(opts);
6851
7642
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bortexcode",
3
- "version": "1.2.8",
3
+ "version": "1.3.0",
4
4
  "description": "Bortex Code CLI - AI coding assistant powered by bortex.site",
5
5
  "homepage": "https://bortex.site",
6
6
  "license": "UNLICENSED",