bortexcode 1.2.9 → 1.4.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 +30 -0
  2. package/bin/bortex.js +594 -41
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -48,6 +48,7 @@ bortexcode
48
48
  bortexcode "explain this function"
49
49
  bortexcode --agent "refactor src/utils.js"
50
50
  bortexcode remote-control
51
+ bortexcode remote-control --cloud
51
52
  bortexcode --remote-control
52
53
  ```
53
54
 
@@ -67,6 +68,7 @@ Common commands:
67
68
  /llm-config sync
68
69
  /remote-control [name]
69
70
  /remote-control --lan
71
+ /remote-control --cloud
70
72
  /rc
71
73
  /exit
72
74
  ```
@@ -87,11 +89,31 @@ For phone access on the same LAN:
87
89
  bortexcode remote-control --remote-lan
88
90
  ```
89
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
+ bortexcode remote-control --cloud --remote-permission full
98
+ ```
99
+
100
+ Cloud Remote Control defaults to `balanced` permissions. It allows inspection,
101
+ status commands, and common test/lint/build shell commands while blocking
102
+ mutating commands such as file writes, patch apply, git stage/commit/discard,
103
+ and arbitrary shell mutations. Use `--remote-permission read-only` for
104
+ inspection-only sessions or `--remote-permission full` only for trusted sessions.
105
+
106
+ The browser UI includes Cancel and Revoke controls. Cancel requests termination
107
+ of the running command. Revoke invalidates the tokenized URL and stops the
108
+ server-mode cloud session.
109
+
90
110
  Inside the REPL:
91
111
 
92
112
  ```text
93
113
  /remote-control My Project
94
114
  /remote-control --lan
115
+ /remote-control --cloud
116
+ /remote-control --cloud --permission full
95
117
  /remote-control stop
96
118
  ```
97
119
 
@@ -115,6 +137,12 @@ Inside the REPL:
115
137
  Disable startup update check
116
138
  --remote-control, --rc [name]
117
139
  Enable browser remote control
140
+ --remote-cloud, --cloud
141
+ Use bortex.site relay instead of opening a local port
142
+ --remote-relay <url>
143
+ Remote relay base URL
144
+ --remote-permission <mode>
145
+ Remote permissions: read-only, balanced, full
118
146
  --remote-lan Bind remote control to 0.0.0.0 for LAN/mobile access
119
147
  --remote-host <host>, --remote-port <port>
120
148
  Remote control bind address
@@ -141,4 +169,6 @@ BORTEX_CLI_ICONS=1
141
169
  BORTEX_NO_UPDATE_CHECK=1
142
170
  BORTEX_REMOTE_HOST
143
171
  BORTEX_REMOTE_PORT
172
+ BORTEX_REMOTE_RELAY_URL
173
+ BORTEX_REMOTE_PERMISSION
144
174
  ```
package/bin/bortex.js CHANGED
@@ -33,6 +33,9 @@ function parseArgs(argv) {
33
33
  _apiKeyExplicit: false,
34
34
  remoteControl: false,
35
35
  remoteControlServerMode: false,
36
+ remoteControlCloud: false,
37
+ remoteControlRelayUrl: process.env.BORTEX_REMOTE_RELAY_URL || '',
38
+ remoteControlPermission: process.env.BORTEX_REMOTE_PERMISSION || 'balanced',
36
39
  remoteControlName: '',
37
40
  remoteControlHost: process.env.BORTEX_REMOTE_HOST || '127.0.0.1',
38
41
  remoteControlPort: Number(process.env.BORTEX_REMOTE_PORT || 0) || 0,
@@ -51,6 +54,10 @@ function parseArgs(argv) {
51
54
  opts.remoteControlServerMode = true;
52
55
  continue;
53
56
  }
57
+ if ((a === 'cloud' || a === 'relay') && opts.remoteControlServerMode) {
58
+ opts.remoteControlCloud = true;
59
+ continue;
60
+ }
54
61
  if (a === '--agent' || a === '-a') {
55
62
  opts.agent = true;
56
63
  continue;
@@ -150,6 +157,41 @@ function parseArgs(argv) {
150
157
  opts.remoteControlName = a.slice('--remote-control='.length);
151
158
  continue;
152
159
  }
160
+ if (a === '--remote-cloud' || a === '--cloud' || a === '--relay') {
161
+ opts.remoteControl = true;
162
+ opts.remoteControlCloud = true;
163
+ if (argv[i + 1] && !String(argv[i + 1]).startsWith('-')) {
164
+ opts.remoteControlName = argv[i + 1];
165
+ i += 1;
166
+ }
167
+ continue;
168
+ }
169
+ if ((a === '--remote-relay' || a === '--relay-url') && argv[i + 1]) {
170
+ opts.remoteControlRelayUrl = argv[i + 1];
171
+ i += 1;
172
+ continue;
173
+ }
174
+ if (a.startsWith('--remote-relay=')) {
175
+ opts.remoteControlRelayUrl = a.slice('--remote-relay='.length);
176
+ continue;
177
+ }
178
+ if (a.startsWith('--relay-url=')) {
179
+ opts.remoteControlRelayUrl = a.slice('--relay-url='.length);
180
+ continue;
181
+ }
182
+ if ((a === '--remote-permission' || a === '--permission') && argv[i + 1]) {
183
+ opts.remoteControlPermission = argv[i + 1];
184
+ i += 1;
185
+ continue;
186
+ }
187
+ if (a.startsWith('--remote-permission=')) {
188
+ opts.remoteControlPermission = a.slice('--remote-permission='.length);
189
+ continue;
190
+ }
191
+ if (a.startsWith('--permission=')) {
192
+ opts.remoteControlPermission = a.slice('--permission='.length);
193
+ continue;
194
+ }
153
195
  if (a === '--remote-lan') {
154
196
  opts.remoteControlHost = '0.0.0.0';
155
197
  continue;
@@ -197,6 +239,7 @@ function usage() {
197
239
  console.log('Usage:');
198
240
  console.log(` ${cliName} [options] [prompt]`);
199
241
  console.log(` ${cliName} remote-control [--name <title>] [--remote-lan]`);
242
+ console.log(` ${cliName} remote-control --cloud [--name <title>]`);
200
243
  console.log(` ${cliName} --api-key <apikey>`);
201
244
  console.log(` ${cliName} "write a python function"`);
202
245
  console.log('');
@@ -218,6 +261,12 @@ function usage() {
218
261
  console.log(' Disable startup update check');
219
262
  console.log(' --remote-control, --rc [name]');
220
263
  console.log(' Enable browser remote control for this session');
264
+ console.log(' --remote-cloud, --cloud');
265
+ console.log(' Use bortex.site relay instead of opening a local port');
266
+ console.log(' --remote-relay <url>');
267
+ console.log(' Remote relay base URL (default: current --url)');
268
+ console.log(' --remote-permission <mode>');
269
+ console.log(' Remote permissions: read-only, balanced, full');
221
270
  console.log(' --remote-lan Bind remote control to 0.0.0.0 for LAN/mobile access');
222
271
  console.log(' --remote-host <host>, --remote-port <port>');
223
272
  console.log(' Remote control bind address');
@@ -230,6 +279,8 @@ function usage() {
230
279
  console.log('Environment:');
231
280
  console.log(' BORTEX_URL Server URL');
232
281
  console.log(' BORTEX_API_KEY API key');
282
+ console.log(' BORTEX_REMOTE_RELAY_URL Remote Control cloud relay URL');
283
+ console.log(' BORTEX_REMOTE_PERMISSION Remote permissions: read-only, balanced, full');
233
284
  }
234
285
 
235
286
  function formatMs(ms) {
@@ -3897,8 +3948,31 @@ async function runToolRunCommand(opts, line) {
3897
3948
  });
3898
3949
  }
3899
3950
 
3951
+ let ACTIVE_REMOTE_CONTROL_EXECUTION_STATE = null;
3952
+
3953
+ function terminateChildProcessTree(child) {
3954
+ if (!child || !child.pid) return;
3955
+ if (process.platform === 'win32') {
3956
+ try {
3957
+ spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
3958
+ stdio: 'ignore',
3959
+ windowsHide: true
3960
+ });
3961
+ return;
3962
+ } catch (_err) {}
3963
+ }
3964
+ try { child.kill('SIGTERM'); } catch (_err) {}
3965
+ setTimeout(() => {
3966
+ try { child.kill('SIGKILL'); } catch (_err) {}
3967
+ }, 1500).unref?.();
3968
+ }
3969
+
3900
3970
  function runChild(command, args, { cwd, shell = false } = {}) {
3901
3971
  return new Promise((resolve) => {
3972
+ const cancelState = ACTIVE_REMOTE_CONTROL_EXECUTION_STATE;
3973
+ if (cancelState?.cancelRequested || cancelState?.revoked || cancelState?.active === false) {
3974
+ return resolve({ ok: false, code: null, stdout: '', stderr: 'Canceled by remote request.' });
3975
+ }
3902
3976
  const child = spawn(command, args, {
3903
3977
  cwd: cwd || process.cwd(),
3904
3978
  shell,
@@ -3907,10 +3981,30 @@ function runChild(command, args, { cwd, shell = false } = {}) {
3907
3981
  });
3908
3982
  let stdout = '';
3909
3983
  let stderr = '';
3984
+ let settled = false;
3985
+ let canceled = false;
3986
+ const finish = (result) => {
3987
+ if (settled) return;
3988
+ settled = true;
3989
+ if (cancelTimer) clearInterval(cancelTimer);
3990
+ resolve(result);
3991
+ };
3992
+ const cancelTimer = cancelState ? setInterval(() => {
3993
+ if (cancelState.cancelRequested || cancelState.revoked || cancelState.active === false) {
3994
+ canceled = true;
3995
+ terminateChildProcessTree(child);
3996
+ }
3997
+ }, 250) : null;
3998
+ if (cancelTimer && typeof cancelTimer.unref === 'function') cancelTimer.unref();
3910
3999
  child.stdout.on('data', (c) => { stdout += String(c || ''); });
3911
4000
  child.stderr.on('data', (c) => { stderr += String(c || ''); });
3912
- child.on('error', (err) => resolve({ ok: false, code: null, stdout, stderr: err.message }));
3913
- child.on('close', (code) => resolve({ ok: code === 0, code, stdout, stderr }));
4001
+ child.on('error', (err) => finish({ ok: false, code: null, stdout, stderr: err.message }));
4002
+ child.on('close', (code) => {
4003
+ const finalStderr = canceled
4004
+ ? `${stderr || ''}${stderr && !stderr.endsWith('\n') ? '\n' : ''}Canceled by remote request.`
4005
+ : stderr;
4006
+ finish({ ok: !canceled && code === 0, code, stdout, stderr: finalStderr });
4007
+ });
3914
4008
  });
3915
4009
  }
3916
4010
 
@@ -3930,6 +4024,7 @@ function printLocalHelp() {
3930
4024
  console.log(' /process-status check running processes');
3931
4025
  console.log(' /port-status check listening ports');
3932
4026
  console.log(' /remote-control [name] [--lan|--host <host>|--port <port>]');
4027
+ console.log(' /remote-control --cloud [name]');
3933
4028
  console.log(' /remote-control stop');
3934
4029
  console.log(' /diff [unstaged|staged|all]');
3935
4030
  console.log(' /stage <file>|--all');
@@ -4342,29 +4437,50 @@ async function captureRemoteControlOutput(fn) {
4342
4437
  async function runRemoteControlPrompt(opts, text) {
4343
4438
  const line = String(text || '').trim();
4344
4439
  if (!line) return { ok: false, output: 'Empty prompt.' };
4440
+ const permission = assessRemoteControlPromptPermission(opts, line);
4441
+ if (!permission.ok) {
4442
+ return {
4443
+ ok: false,
4444
+ output: `Remote command blocked (${permission.mode}): ${permission.reason}.\nRestart with --remote-permission full only for a trusted session.`
4445
+ };
4446
+ }
4345
4447
  pushCliHistory(opts, `[remote] ${line}`);
4346
4448
  try { saveCliWorkspaceState(opts); } catch (_err) {}
4347
- const captured = await captureRemoteControlOutput(async () => {
4348
- if (line === '/exit' || line === '/quit') {
4349
- console.log('Remote clients cannot exit the local terminal process. Stop it locally with Ctrl+C.');
4350
- return;
4351
- }
4352
- const localResult = await handleLocalCommand(opts, line);
4353
- if (localResult.handled) return;
4354
- const localIntent = classifyLocalPromptIntent(line);
4355
- if (localIntent?.command) {
4356
- const intentResult = await handleLocalCommand(opts, localIntent.command);
4357
- if (intentResult.handled) return;
4358
- }
4359
- const naturalFileAction = await runNaturalLocalFileAction(opts, line);
4360
- if (naturalFileAction.handled) return;
4361
- if (opts.offline) {
4362
- console.log('Offline mode: use slash commands and local tools from this remote session.');
4363
- return;
4364
- }
4365
- const data = await askServer(opts, line);
4366
- printResponse(opts, data);
4367
- });
4449
+ const previousRemoteExecution = ACTIVE_REMOTE_CONTROL_EXECUTION_STATE;
4450
+ ACTIVE_REMOTE_CONTROL_EXECUTION_STATE = opts._remoteControlExecutionState || null;
4451
+ let captured;
4452
+ try {
4453
+ captured = await captureRemoteControlOutput(async () => {
4454
+ if (line === '/exit' || line === '/quit') {
4455
+ console.log('Remote clients cannot exit the local terminal process. Stop it locally with Ctrl+C.');
4456
+ return;
4457
+ }
4458
+ const localResult = await handleLocalCommand(opts, line);
4459
+ if (localResult.handled) return;
4460
+ const localIntent = classifyLocalPromptIntent(line);
4461
+ if (localIntent?.command) {
4462
+ const intentPermission = assessRemoteControlPromptPermission(opts, localIntent.command);
4463
+ if (!intentPermission.ok) {
4464
+ console.log(`Remote inferred command blocked (${intentPermission.mode}): ${intentPermission.reason}.`);
4465
+ return;
4466
+ }
4467
+ const intentResult = await handleLocalCommand(opts, localIntent.command);
4468
+ if (intentResult.handled) return;
4469
+ }
4470
+ if (!permission.skipNaturalActions) {
4471
+ const naturalFileAction = await runNaturalLocalFileAction(opts, line);
4472
+ if (naturalFileAction.handled) return;
4473
+ }
4474
+ if (opts.offline) {
4475
+ console.log('Offline mode: use slash commands and local tools from this remote session.');
4476
+ return;
4477
+ }
4478
+ const data = await askServer(opts, line);
4479
+ printResponse(opts, data);
4480
+ });
4481
+ } finally {
4482
+ ACTIVE_REMOTE_CONTROL_EXECUTION_STATE = previousRemoteExecution;
4483
+ }
4368
4484
  const output = `${captured.stdout || ''}${captured.stderr || ''}`.trim();
4369
4485
  return { ok: true, output: output || '(no output)' };
4370
4486
  }
@@ -4377,12 +4493,13 @@ async function startRemoteControlServer(opts, overrides = {}) {
4377
4493
 
4378
4494
  const http = require('http');
4379
4495
  const crypto = require('crypto');
4496
+ opts.remoteControlPermission = getRemoteControlPermissionMode(opts, overrides);
4380
4497
  const host = String(overrides.host || opts.remoteControlHost || '127.0.0.1').trim() || '127.0.0.1';
4381
4498
  const port = Math.max(0, Math.min(65535, Number(overrides.port ?? opts.remoteControlPort ?? 0) || 0));
4382
4499
  const token = crypto.randomBytes(24).toString('hex');
4383
4500
  const state = {
4384
4501
  id: crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`,
4385
- name: String(overrides.name || opts.remoteControlName || path.basename(opts.cwd || process.cwd()) || os.hostname() || 'Bortex Code').trim(),
4502
+ name: getRemoteControlDisplayName(opts, overrides),
4386
4503
  host,
4387
4504
  port,
4388
4505
  token,
@@ -4496,11 +4613,377 @@ async function stopRemoteControlServer(opts) {
4496
4613
  return true;
4497
4614
  }
4498
4615
 
4616
+ function getRemoteControlDisplayName(opts, overrides = {}) {
4617
+ return String(overrides.name || opts.remoteControlName || path.basename(opts.cwd || process.cwd()) || os.hostname() || 'Bortex Code').trim();
4618
+ }
4619
+
4620
+ function getRemoteControlStatus(opts) {
4621
+ if (opts.remoteControlCloudSession?.active) return 'cloud';
4622
+ if (opts.remoteControlSession?.server) return 'local';
4623
+ return 'off';
4624
+ }
4625
+
4626
+ function getRemoteControlRelayBase(opts, overrides = {}) {
4627
+ return String(overrides.relayUrl || opts.remoteControlRelayUrl || opts.url || 'https://bortex.site').replace(/\/+$/, '');
4628
+ }
4629
+
4630
+ function normalizeRemoteControlPermissionMode(value) {
4631
+ const v = String(value || 'balanced').trim().toLowerCase();
4632
+ if (['full', 'trusted', 'unsafe', 'yolo'].includes(v)) return 'full';
4633
+ if (['read-only', 'readonly', 'read', 'safe', 'ro'].includes(v)) return 'read-only';
4634
+ return 'balanced';
4635
+ }
4636
+
4637
+ function getRemoteControlPermissionMode(opts, overrides = {}) {
4638
+ return normalizeRemoteControlPermissionMode(overrides.permission || opts.remoteControlPermission || 'balanced');
4639
+ }
4640
+
4641
+ function isReadOnlyRemoteSlashCommand(line) {
4642
+ const [cmd, ...rest] = parseWords(line);
4643
+ const lc = String(cmd || '').toLowerCase();
4644
+ if (!lc.startsWith('/')) return false;
4645
+ if ([
4646
+ '/help', '/commands', '/menu', '/status', '/pwd', '/ls', '/tree', '/read',
4647
+ '/diff', '/hunks', '/show-hunk', '/ssh-status', '/sys-status',
4648
+ '/process-status', '/port-status', '/history'
4649
+ ].includes(lc)) return true;
4650
+ if (lc === '/llm-config') {
4651
+ const sub = String(rest[0] || 'show').toLowerCase();
4652
+ return !sub || ['show', 'status'].includes(sub);
4653
+ }
4654
+ if (lc === '/git') {
4655
+ const sub = String(rest[0] || '').toLowerCase();
4656
+ return ['status', 'diff', 'log', 'show', 'branch', 'rev-parse', 'remote'].includes(sub);
4657
+ }
4658
+ return false;
4659
+ }
4660
+
4661
+ function isBalancedRemoteShellCommand(cmdText) {
4662
+ const text = String(cmdText || '').trim();
4663
+ const lower = text.toLowerCase();
4664
+ if (!text) return false;
4665
+ if (/[;&|`$<>]/.test(text)) return false;
4666
+ if (/\b(rm|del|erase|rmdir|rd|move|mv|copy|cp|curl|wget|scp|ssh|chmod|chown|sudo|su|kill|pkill|taskkill|format|shutdown|reboot)\b/i.test(text)) return false;
4667
+ if (/\b(git\s+(add|commit|reset|restore|checkout|switch|clean|stash|push|pull|merge|rebase|apply|am|cherry-pick))\b/i.test(text)) return false;
4668
+ if (/^(npm|pnpm|yarn)\s+(test|run\s+(test|lint|build|check|typecheck)|exec\s+tsc)\b/i.test(text)) return true;
4669
+ if (/^node\s+--check\s+\S+/i.test(text)) return true;
4670
+ if (/^(git\s+(status|diff|log|show|branch|rev-parse)|rg|grep|findstr|ls|dir|pwd|cat|type)\b/i.test(text)) return true;
4671
+ return false;
4672
+ }
4673
+
4674
+ function isBalancedRemoteSlashCommand(line) {
4675
+ if (isReadOnlyRemoteSlashCommand(line)) return true;
4676
+ const [cmd, ...rest] = parseWords(line);
4677
+ const lc = String(cmd || '').toLowerCase();
4678
+ if (lc === '/sh') {
4679
+ const cmdText = line.slice(line.indexOf('/sh') + 3).trim();
4680
+ return isBalancedRemoteShellCommand(cmdText);
4681
+ }
4682
+ if (lc === '/agent') return true;
4683
+ if (lc === '/ux') return true;
4684
+ if (lc === '/remote-control' || lc === '/remote' || lc === '/rc') return true;
4685
+ if (lc === '/agent-run') {
4686
+ return rest.some((x) => String(x).toLowerCase() === '--dry');
4687
+ }
4688
+ return false;
4689
+ }
4690
+
4691
+ function assessRemoteControlPromptPermission(opts, line) {
4692
+ const mode = getRemoteControlPermissionMode(opts);
4693
+ const text = String(line || '').trim();
4694
+ if (!text) return { ok: false, mode, reason: 'empty prompt' };
4695
+ if (mode === 'full') return { ok: true, mode };
4696
+ if (!text.startsWith('/')) return { ok: true, mode, skipNaturalActions: true };
4697
+ if (mode === 'read-only') {
4698
+ if (isReadOnlyRemoteSlashCommand(text)) return { ok: true, mode, skipNaturalActions: true };
4699
+ return { ok: false, mode, reason: 'read-only mode allows inspection commands only' };
4700
+ }
4701
+ if (isBalancedRemoteSlashCommand(text)) return { ok: true, mode, skipNaturalActions: true };
4702
+ return { ok: false, mode, reason: 'balanced mode blocks mutating or unsafe remote commands' };
4703
+ }
4704
+
4705
+ function remoteControlSleep(ms) {
4706
+ return new Promise((resolve) => setTimeout(resolve, ms));
4707
+ }
4708
+
4709
+ function withRemoteControlQuery(rawUrl, baseUrl, params = {}) {
4710
+ const urlObj = new URL(rawUrl, baseUrl);
4711
+ Object.entries(params).forEach(([key, value]) => {
4712
+ if (value !== undefined && value !== null && value !== '') {
4713
+ urlObj.searchParams.set(key, String(value));
4714
+ }
4715
+ });
4716
+ return urlObj.toString();
4717
+ }
4718
+
4719
+ async function fetchRemoteControlJson(url, options = {}, timeoutMs = 30000) {
4720
+ const controller = new AbortController();
4721
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
4722
+ if (typeof timeoutId.unref === 'function') timeoutId.unref();
4723
+ try {
4724
+ const res = await fetch(url, {
4725
+ ...options,
4726
+ headers: {
4727
+ Accept: 'application/json',
4728
+ ...(options.headers || {})
4729
+ },
4730
+ signal: controller.signal
4731
+ });
4732
+ const text = await res.text();
4733
+ let data = null;
4734
+ if (text) {
4735
+ try { data = JSON.parse(text); } catch (_err) { data = null; }
4736
+ }
4737
+ if (!res.ok) {
4738
+ const detail = data?.error || data?.message || text || res.statusText || 'request failed';
4739
+ throw new Error(`HTTP ${res.status}: ${detail}`);
4740
+ }
4741
+ if (data?.ok === false) {
4742
+ throw new Error(data.error || data.message || 'request failed');
4743
+ }
4744
+ return data || {};
4745
+ } finally {
4746
+ clearTimeout(timeoutId);
4747
+ }
4748
+ }
4749
+
4750
+ async function postCloudRemoteHeartbeat(state, extra = {}) {
4751
+ const heartbeatUrl = withRemoteControlQuery(
4752
+ state.heartbeatUrl,
4753
+ state.baseUrl,
4754
+ { agentToken: state.agentToken }
4755
+ );
4756
+ await fetchRemoteControlJson(heartbeatUrl, {
4757
+ method: 'POST',
4758
+ headers: { 'Content-Type': 'application/json' },
4759
+ body: JSON.stringify({ cwd: state.cwd, version: CLI_VERSION, ...extra })
4760
+ }, 12000);
4761
+ }
4762
+
4763
+ async function postCloudRemoteResult(state, commandId, result) {
4764
+ const resultUrl = withRemoteControlQuery(
4765
+ state.resultUrl,
4766
+ state.baseUrl,
4767
+ { agentToken: state.agentToken }
4768
+ );
4769
+ await fetchRemoteControlJson(resultUrl, {
4770
+ method: 'POST',
4771
+ headers: { 'Content-Type': 'application/json' },
4772
+ body: JSON.stringify({
4773
+ commandId,
4774
+ ok: !!result.ok,
4775
+ output: String(result.output || '')
4776
+ })
4777
+ }, 35000);
4778
+ }
4779
+
4780
+ async function pollCloudRemoteControlControlLoop(state) {
4781
+ while (state.active) {
4782
+ try {
4783
+ const controlUrl = withRemoteControlQuery(
4784
+ state.controlUrl,
4785
+ state.baseUrl,
4786
+ { agentToken: state.agentToken }
4787
+ );
4788
+ const data = await fetchRemoteControlJson(controlUrl, { method: 'GET' }, 12000);
4789
+ if (data.revoked) {
4790
+ state.revoked = true;
4791
+ state.cancelRequested = true;
4792
+ state.active = false;
4793
+ console.log('Cloud Remote Control revoked by remote client.');
4794
+ break;
4795
+ }
4796
+ if (data.cancelRequested) {
4797
+ state.cancelRequested = true;
4798
+ state.cancelCommandId = data.cancelCommandId || null;
4799
+ }
4800
+ state.controlErrorCount = 0;
4801
+ } catch (err) {
4802
+ state.controlErrorCount = (state.controlErrorCount || 0) + 1;
4803
+ if (/revoked|410/i.test(String(err.message || err))) {
4804
+ state.revoked = true;
4805
+ state.cancelRequested = true;
4806
+ state.active = false;
4807
+ break;
4808
+ }
4809
+ }
4810
+ await remoteControlSleep(Math.min(3000, 1000 + (state.controlErrorCount || 0) * 250));
4811
+ }
4812
+ }
4813
+
4814
+ async function pollCloudRemoteControlLoop(opts, state) {
4815
+ let lastHeartbeatAt = 0;
4816
+ while (state.active) {
4817
+ let delayMs = 1200;
4818
+ try {
4819
+ const pollUrl = withRemoteControlQuery(
4820
+ state.pollUrl,
4821
+ state.baseUrl,
4822
+ { agentToken: state.agentToken, after: state.lastCommandId || 0 }
4823
+ );
4824
+ const data = await fetchRemoteControlJson(pollUrl, { method: 'GET' }, 35000);
4825
+ if (data.revoked) {
4826
+ state.revoked = true;
4827
+ state.cancelRequested = true;
4828
+ state.active = false;
4829
+ console.log('Cloud Remote Control revoked by remote client.');
4830
+ break;
4831
+ }
4832
+ if (data.cancelRequested) {
4833
+ state.cancelRequested = true;
4834
+ state.cancelCommandId = data.cancelCommandId || null;
4835
+ }
4836
+ const commands = Array.isArray(data.commands) ? data.commands : [];
4837
+ state.lastSeenAt = Date.now();
4838
+ state.errorCount = 0;
4839
+
4840
+ for (const command of commands) {
4841
+ if (!state.active) break;
4842
+ const commandId = Number(command?.id || 0);
4843
+ if (commandId > (state.lastCommandId || 0)) state.lastCommandId = commandId;
4844
+ const text = String(command?.text || command?.prompt || '').trim();
4845
+ if (!text) continue;
4846
+ state.cancelRequested = false;
4847
+ state.cancelCommandId = null;
4848
+ state.busy = true;
4849
+ opts._remoteControlExecutionState = state;
4850
+ let result;
4851
+ try {
4852
+ result = await runRemoteControlPrompt(opts, text);
4853
+ } catch (err) {
4854
+ result = { ok: false, output: err.stack || err.message || String(err) };
4855
+ } finally {
4856
+ state.busy = false;
4857
+ delete opts._remoteControlExecutionState;
4858
+ }
4859
+ if (state.cancelRequested && result.ok) {
4860
+ result = { ok: false, output: 'Canceled by remote request.' };
4861
+ }
4862
+ await postCloudRemoteResult(state, commandId, result);
4863
+ if (state.cancelRequested) {
4864
+ state.cancelRequested = false;
4865
+ state.cancelCommandId = null;
4866
+ }
4867
+ }
4868
+
4869
+ if (!commands.length && state.cancelRequested && !state.busy) {
4870
+ await postCloudRemoteHeartbeat(state, { cancelAck: true });
4871
+ state.cancelRequested = false;
4872
+ state.cancelCommandId = null;
4873
+ }
4874
+ if (!commands.length && Date.now() - lastHeartbeatAt > 10000) {
4875
+ lastHeartbeatAt = Date.now();
4876
+ await postCloudRemoteHeartbeat(state);
4877
+ }
4878
+ if (commands.length) delayMs = 250;
4879
+ } catch (err) {
4880
+ if (state.active) {
4881
+ state.errorCount = (state.errorCount || 0) + 1;
4882
+ const now = Date.now();
4883
+ if (!state.lastErrorAt || now - state.lastErrorAt > 15000) {
4884
+ console.error(`Cloud Remote Control error: ${err.message || String(err)}`);
4885
+ state.lastErrorAt = now;
4886
+ }
4887
+ delayMs = Math.min(5000, 1000 + state.errorCount * 500);
4888
+ }
4889
+ } finally {
4890
+ state.busy = false;
4891
+ }
4892
+ await remoteControlSleep(delayMs);
4893
+ }
4894
+ }
4895
+
4896
+ async function startCloudRemoteControlSession(opts, overrides = {}) {
4897
+ if (opts.remoteControlCloudSession?.active) {
4898
+ console.log(`Cloud Remote Control already active: ${opts.remoteControlCloudSession.url}`);
4899
+ return opts.remoteControlCloudSession;
4900
+ }
4901
+
4902
+ const baseUrl = getRemoteControlRelayBase(opts, overrides);
4903
+ const name = getRemoteControlDisplayName(opts, overrides);
4904
+ const permission = getRemoteControlPermissionMode(opts, overrides);
4905
+ opts.remoteControlPermission = permission;
4906
+ const payload = {
4907
+ name,
4908
+ cwd: opts.cwd,
4909
+ version: CLI_VERSION,
4910
+ machine: os.hostname(),
4911
+ platform: process.platform,
4912
+ node: process.version,
4913
+ mode: opts.agent ? 'agent' : 'chat',
4914
+ permission
4915
+ };
4916
+ const data = await fetchRemoteControlJson(`${baseUrl}/api/bortex-code/remote/register`, {
4917
+ method: 'POST',
4918
+ headers: { 'Content-Type': 'application/json' },
4919
+ body: JSON.stringify(payload)
4920
+ }, 30000);
4921
+
4922
+ const sessionId = String(data.sessionId || '').trim();
4923
+ const agentToken = String(data.agentToken || '').trim();
4924
+ if (!sessionId || !agentToken) {
4925
+ throw new Error('Remote relay registration did not return a session token.');
4926
+ }
4927
+
4928
+ const state = {
4929
+ active: true,
4930
+ cloud: true,
4931
+ baseUrl,
4932
+ sessionId,
4933
+ agentToken,
4934
+ clientToken: data.clientToken || '',
4935
+ name,
4936
+ permission,
4937
+ cwd: opts.cwd,
4938
+ url: data.url || `${baseUrl}/bortex-code/remote?session=${encodeURIComponent(sessionId)}`,
4939
+ pollUrl: data.pollUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/poll`,
4940
+ resultUrl: data.resultUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/result`,
4941
+ heartbeatUrl: data.heartbeatUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/heartbeat`,
4942
+ controlUrl: data.controlUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/control`,
4943
+ lastCommandId: 0,
4944
+ startedAt: Date.now(),
4945
+ busy: false,
4946
+ errorCount: 0
4947
+ };
4948
+ state.stop = async () => {
4949
+ state.active = false;
4950
+ };
4951
+ opts.remoteControlCloudSession = state;
4952
+ state.pollPromise = pollCloudRemoteControlLoop(opts, state).catch((err) => {
4953
+ if (state.active) console.error(`Cloud Remote Control stopped unexpectedly: ${err.message || String(err)}`);
4954
+ });
4955
+ state.controlPromise = pollCloudRemoteControlControlLoop(state).catch((err) => {
4956
+ if (state.active) console.error(`Cloud Remote Control control loop stopped unexpectedly: ${err.message || String(err)}`);
4957
+ });
4958
+
4959
+ console.log(`Cloud Remote Control active: ${state.name}`);
4960
+ console.log(`URL: ${state.url}`);
4961
+ console.log(`Permission: ${state.permission}`);
4962
+ console.log('No inbound port required. Keep this process running.');
4963
+ console.log('Security: keep this tokenized URL private. Anyone with the URL can control this Bortex Code session.');
4964
+ return state;
4965
+ }
4966
+
4967
+ async function stopCloudRemoteControlSession(opts) {
4968
+ const state = opts.remoteControlCloudSession;
4969
+ if (!state?.active) {
4970
+ console.log('Cloud Remote Control is not active.');
4971
+ return false;
4972
+ }
4973
+ await state.stop();
4974
+ opts.remoteControlCloudSession = null;
4975
+ console.log('Cloud Remote Control stopped.');
4976
+ return true;
4977
+ }
4978
+
4499
4979
  function parseRemoteControlSlashArgs(rest = [], opts = {}) {
4500
4980
  const out = {
4501
4981
  name: '',
4502
4982
  host: opts.remoteControlHost || '127.0.0.1',
4503
- port: Number(opts.remoteControlPort || 0) || 0
4983
+ port: Number(opts.remoteControlPort || 0) || 0,
4984
+ relayUrl: opts.remoteControlRelayUrl || '',
4985
+ permission: opts.remoteControlPermission || 'balanced',
4986
+ cloud: false
4504
4987
  };
4505
4988
  const nameParts = [];
4506
4989
  for (let i = 0; i < rest.length; i += 1) {
@@ -4513,6 +4996,36 @@ function parseRemoteControlSlashArgs(rest = [], opts = {}) {
4513
4996
  out.host = '0.0.0.0';
4514
4997
  continue;
4515
4998
  }
4999
+ if (a === 'cloud' || a === '--cloud' || a === '--remote-cloud' || a === '--relay') {
5000
+ out.cloud = true;
5001
+ continue;
5002
+ }
5003
+ if ((a === '--relay-url' || a === '--remote-relay') && rest[i + 1]) {
5004
+ out.relayUrl = String(rest[i + 1]);
5005
+ i += 1;
5006
+ continue;
5007
+ }
5008
+ if (a.startsWith('--relay-url=')) {
5009
+ out.relayUrl = a.slice('--relay-url='.length);
5010
+ continue;
5011
+ }
5012
+ if (a.startsWith('--remote-relay=')) {
5013
+ out.relayUrl = a.slice('--remote-relay='.length);
5014
+ continue;
5015
+ }
5016
+ if ((a === '--permission' || a === '--remote-permission') && rest[i + 1]) {
5017
+ out.permission = String(rest[i + 1]);
5018
+ i += 1;
5019
+ continue;
5020
+ }
5021
+ if (a.startsWith('--permission=')) {
5022
+ out.permission = a.slice('--permission='.length);
5023
+ continue;
5024
+ }
5025
+ if (a.startsWith('--remote-permission=')) {
5026
+ out.permission = a.slice('--remote-permission='.length);
5027
+ continue;
5028
+ }
4516
5029
  if ((a === '--host' || a === '--remote-host') && rest[i + 1]) {
4517
5030
  out.host = String(rest[i + 1]);
4518
5031
  i += 1;
@@ -5835,13 +6348,18 @@ async function handleLocalCommand(opts, line) {
5835
6348
  const done = todoItems.filter((t) => t.done).length;
5836
6349
  const runPending = Array.isArray(opts.runState?.steps) ? opts.runState.steps.filter((s) => s.status === 'pending').length : 0;
5837
6350
  console.log(formatModeLine(opts));
5838
- console.log(`Todo: ${done}/${todoItems.length} | Plan: ${opts.plan?.goal ? 'active' : 'none'} | Run: ${opts.runState?.goal ? `active (${runPending} pending)` : 'none'} | Remote: ${opts.remoteControlSession?.server ? 'active' : 'off'}`);
6351
+ console.log(`Todo: ${done}/${todoItems.length} | Plan: ${opts.plan?.goal ? 'active' : 'none'} | Run: ${opts.runState?.goal ? `active (${runPending} pending)` : 'none'} | Remote: ${getRemoteControlStatus(opts)}`);
5839
6352
  return { handled: true };
5840
6353
  }
5841
6354
  if (lc === '/remote-control' || lc === '/remote' || lc === '/rc') {
5842
6355
  const parsed = parseRemoteControlSlashArgs(rest, opts);
5843
- if (parsed.stop || opts.remoteControlSession?.server) {
5844
- await stopRemoteControlServer(opts);
6356
+ if (parsed.stop || opts.remoteControlSession?.server || opts.remoteControlCloudSession?.active) {
6357
+ if (opts.remoteControlSession?.server) await stopRemoteControlServer(opts);
6358
+ if (opts.remoteControlCloudSession?.active) await stopCloudRemoteControlSession(opts);
6359
+ return { handled: true };
6360
+ }
6361
+ if (parsed.cloud) {
6362
+ await startCloudRemoteControlSession(opts, parsed);
5845
6363
  return { handled: true };
5846
6364
  }
5847
6365
  await startRemoteControlServer(opts, parsed);
@@ -7010,6 +7528,7 @@ const SLASH_COMMANDS = [
7010
7528
  ['/help', 'Show command help'],
7011
7529
  ['/remote-control [name]', 'Control this local session from a browser'],
7012
7530
  ['/remote-control --lan', 'Expose Remote Control on the local network'],
7531
+ ['/remote-control --cloud', 'Control this session through bortex.site relay'],
7013
7532
  ['/rc', 'Toggle Remote Control'],
7014
7533
  ['/llm-config show', 'Show cached LLM configuration'],
7015
7534
  ['/llm-config sync', 'Sync LLM configuration from Bortex'],
@@ -7186,11 +7705,18 @@ async function runRepl(opts) {
7186
7705
  };
7187
7706
  opts._askInput = async (prompt) => new Promise((resolve) => rl.question(prompt, resolve));
7188
7707
  if (opts.remoteControl) {
7189
- await startRemoteControlServer(opts, {
7190
- name: opts.remoteControlName,
7191
- host: opts.remoteControlHost,
7192
- port: opts.remoteControlPort
7193
- });
7708
+ if (opts.remoteControlCloud) {
7709
+ await startCloudRemoteControlSession(opts, {
7710
+ name: opts.remoteControlName,
7711
+ relayUrl: opts.remoteControlRelayUrl
7712
+ });
7713
+ } else {
7714
+ await startRemoteControlServer(opts, {
7715
+ name: opts.remoteControlName,
7716
+ host: opts.remoteControlHost,
7717
+ port: opts.remoteControlPort
7718
+ });
7719
+ }
7194
7720
  }
7195
7721
 
7196
7722
  const question = () => new Promise((resolve) => {
@@ -7216,7 +7742,7 @@ async function runRepl(opts) {
7216
7742
  const done = todoItems.filter((t) => t.done).length;
7217
7743
  console.log(formatModeLine(opts));
7218
7744
  const runPending = Array.isArray(opts.runState?.steps) ? opts.runState.steps.filter((s) => s.status === 'pending').length : 0;
7219
- console.log(`Todo: ${done}/${todoItems.length} | Plan: ${opts.plan?.goal ? 'active' : 'none'} | Run: ${opts.runState?.goal ? `active (${runPending} pending)` : 'none'}`);
7745
+ console.log(`Todo: ${done}/${todoItems.length} | Plan: ${opts.plan?.goal ? 'active' : 'none'} | Run: ${opts.runState?.goal ? `active (${runPending} pending)` : 'none'} | Remote: ${getRemoteControlStatus(opts)}`);
7220
7746
  continue;
7221
7747
  }
7222
7748
  if (line === '/help') {
@@ -7266,6 +7792,9 @@ async function runRepl(opts) {
7266
7792
  if (opts.remoteControlSession?.server) {
7267
7793
  await stopRemoteControlServer(opts);
7268
7794
  }
7795
+ if (opts.remoteControlCloudSession?.active) {
7796
+ await stopCloudRemoteControlSession(opts);
7797
+ }
7269
7798
  try { saveCliWorkspaceState(opts); } catch (_err) { }
7270
7799
  delete opts._readMultiline;
7271
7800
  delete opts._askInput;
@@ -7317,23 +7846,47 @@ async function main() {
7317
7846
  }
7318
7847
 
7319
7848
  if (opts.remoteControlServerMode) {
7320
- await startRemoteControlServer(opts, {
7321
- name: opts.remoteControlName,
7322
- host: opts.remoteControlHost,
7323
- port: opts.remoteControlPort
7324
- });
7849
+ let remoteState = null;
7850
+ if (opts.remoteControlCloud) {
7851
+ remoteState = await startCloudRemoteControlSession(opts, {
7852
+ name: opts.remoteControlName,
7853
+ relayUrl: opts.remoteControlRelayUrl
7854
+ });
7855
+ } else {
7856
+ remoteState = await startRemoteControlServer(opts, {
7857
+ name: opts.remoteControlName,
7858
+ host: opts.remoteControlHost,
7859
+ port: opts.remoteControlPort
7860
+ });
7861
+ }
7325
7862
  console.log('Server mode: keep this process running. Press Ctrl+C to stop Remote Control.');
7326
- await new Promise((resolve) => {
7863
+ const signalPromise = new Promise((resolve) => {
7327
7864
  let stopping = false;
7328
7865
  const stop = async () => {
7329
7866
  if (stopping) return;
7330
7867
  stopping = true;
7331
- try { await stopRemoteControlServer(opts); } catch (_err) {}
7868
+ if (opts.remoteControlSession?.server) {
7869
+ try { await stopRemoteControlServer(opts); } catch (_err) {}
7870
+ }
7871
+ if (opts.remoteControlCloudSession?.active) {
7872
+ try { await stopCloudRemoteControlSession(opts); } catch (_err) {}
7873
+ }
7332
7874
  resolve();
7333
7875
  };
7334
7876
  process.once('SIGINT', stop);
7335
7877
  process.once('SIGTERM', stop);
7336
7878
  });
7879
+ const remoteDonePromises = [];
7880
+ if (remoteState?.pollPromise) remoteDonePromises.push(remoteState.pollPromise);
7881
+ if (remoteState?.controlPromise) remoteDonePromises.push(remoteState.controlPromise);
7882
+ if (remoteDonePromises.length) {
7883
+ await Promise.race([signalPromise, ...remoteDonePromises]);
7884
+ if (opts.remoteControlCloudSession?.active) {
7885
+ await stopCloudRemoteControlSession(opts);
7886
+ }
7887
+ } else {
7888
+ await signalPromise;
7889
+ }
7337
7890
  return;
7338
7891
  }
7339
7892
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bortexcode",
3
- "version": "1.2.9",
3
+ "version": "1.4.0",
4
4
  "description": "Bortex Code CLI - AI coding assistant powered by bortex.site",
5
5
  "homepage": "https://bortex.site",
6
6
  "license": "UNLICENSED",