bortexcode 1.3.0 → 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 +15 -0
  2. package/bin/bortex.js +282 -29
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -94,14 +94,26 @@ For remote access without opening an inbound port, use the bortex.site relay:
94
94
  ```bash
95
95
  bortexcode remote-control --cloud --name "My Project"
96
96
  bortexcode --remote-cloud "My Project"
97
+ bortexcode remote-control --cloud --remote-permission full
97
98
  ```
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
+
99
110
  Inside the REPL:
100
111
 
101
112
  ```text
102
113
  /remote-control My Project
103
114
  /remote-control --lan
104
115
  /remote-control --cloud
116
+ /remote-control --cloud --permission full
105
117
  /remote-control stop
106
118
  ```
107
119
 
@@ -129,6 +141,8 @@ Inside the REPL:
129
141
  Use bortex.site relay instead of opening a local port
130
142
  --remote-relay <url>
131
143
  Remote relay base URL
144
+ --remote-permission <mode>
145
+ Remote permissions: read-only, balanced, full
132
146
  --remote-lan Bind remote control to 0.0.0.0 for LAN/mobile access
133
147
  --remote-host <host>, --remote-port <port>
134
148
  Remote control bind address
@@ -156,4 +170,5 @@ BORTEX_NO_UPDATE_CHECK=1
156
170
  BORTEX_REMOTE_HOST
157
171
  BORTEX_REMOTE_PORT
158
172
  BORTEX_REMOTE_RELAY_URL
173
+ BORTEX_REMOTE_PERMISSION
159
174
  ```
package/bin/bortex.js CHANGED
@@ -35,6 +35,7 @@ function parseArgs(argv) {
35
35
  remoteControlServerMode: false,
36
36
  remoteControlCloud: false,
37
37
  remoteControlRelayUrl: process.env.BORTEX_REMOTE_RELAY_URL || '',
38
+ remoteControlPermission: process.env.BORTEX_REMOTE_PERMISSION || 'balanced',
38
39
  remoteControlName: '',
39
40
  remoteControlHost: process.env.BORTEX_REMOTE_HOST || '127.0.0.1',
40
41
  remoteControlPort: Number(process.env.BORTEX_REMOTE_PORT || 0) || 0,
@@ -178,6 +179,19 @@ function parseArgs(argv) {
178
179
  opts.remoteControlRelayUrl = a.slice('--relay-url='.length);
179
180
  continue;
180
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
+ }
181
195
  if (a === '--remote-lan') {
182
196
  opts.remoteControlHost = '0.0.0.0';
183
197
  continue;
@@ -251,6 +265,8 @@ function usage() {
251
265
  console.log(' Use bortex.site relay instead of opening a local port');
252
266
  console.log(' --remote-relay <url>');
253
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');
254
270
  console.log(' --remote-lan Bind remote control to 0.0.0.0 for LAN/mobile access');
255
271
  console.log(' --remote-host <host>, --remote-port <port>');
256
272
  console.log(' Remote control bind address');
@@ -264,6 +280,7 @@ function usage() {
264
280
  console.log(' BORTEX_URL Server URL');
265
281
  console.log(' BORTEX_API_KEY API key');
266
282
  console.log(' BORTEX_REMOTE_RELAY_URL Remote Control cloud relay URL');
283
+ console.log(' BORTEX_REMOTE_PERMISSION Remote permissions: read-only, balanced, full');
267
284
  }
268
285
 
269
286
  function formatMs(ms) {
@@ -3931,8 +3948,31 @@ async function runToolRunCommand(opts, line) {
3931
3948
  });
3932
3949
  }
3933
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
+
3934
3970
  function runChild(command, args, { cwd, shell = false } = {}) {
3935
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
+ }
3936
3976
  const child = spawn(command, args, {
3937
3977
  cwd: cwd || process.cwd(),
3938
3978
  shell,
@@ -3941,10 +3981,30 @@ function runChild(command, args, { cwd, shell = false } = {}) {
3941
3981
  });
3942
3982
  let stdout = '';
3943
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();
3944
3999
  child.stdout.on('data', (c) => { stdout += String(c || ''); });
3945
4000
  child.stderr.on('data', (c) => { stderr += String(c || ''); });
3946
- child.on('error', (err) => resolve({ ok: false, code: null, stdout, stderr: err.message }));
3947
- 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
+ });
3948
4008
  });
3949
4009
  }
3950
4010
 
@@ -4377,29 +4437,50 @@ async function captureRemoteControlOutput(fn) {
4377
4437
  async function runRemoteControlPrompt(opts, text) {
4378
4438
  const line = String(text || '').trim();
4379
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
+ }
4380
4447
  pushCliHistory(opts, `[remote] ${line}`);
4381
4448
  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
- });
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
+ }
4403
4484
  const output = `${captured.stdout || ''}${captured.stderr || ''}`.trim();
4404
4485
  return { ok: true, output: output || '(no output)' };
4405
4486
  }
@@ -4412,6 +4493,7 @@ async function startRemoteControlServer(opts, overrides = {}) {
4412
4493
 
4413
4494
  const http = require('http');
4414
4495
  const crypto = require('crypto');
4496
+ opts.remoteControlPermission = getRemoteControlPermissionMode(opts, overrides);
4415
4497
  const host = String(overrides.host || opts.remoteControlHost || '127.0.0.1').trim() || '127.0.0.1';
4416
4498
  const port = Math.max(0, Math.min(65535, Number(overrides.port ?? opts.remoteControlPort ?? 0) || 0));
4417
4499
  const token = crypto.randomBytes(24).toString('hex');
@@ -4545,6 +4627,81 @@ function getRemoteControlRelayBase(opts, overrides = {}) {
4545
4627
  return String(overrides.relayUrl || opts.remoteControlRelayUrl || opts.url || 'https://bortex.site').replace(/\/+$/, '');
4546
4628
  }
4547
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
+
4548
4705
  function remoteControlSleep(ms) {
4549
4706
  return new Promise((resolve) => setTimeout(resolve, ms));
4550
4707
  }
@@ -4590,7 +4747,7 @@ async function fetchRemoteControlJson(url, options = {}, timeoutMs = 30000) {
4590
4747
  }
4591
4748
  }
4592
4749
 
4593
- async function postCloudRemoteHeartbeat(state) {
4750
+ async function postCloudRemoteHeartbeat(state, extra = {}) {
4594
4751
  const heartbeatUrl = withRemoteControlQuery(
4595
4752
  state.heartbeatUrl,
4596
4753
  state.baseUrl,
@@ -4599,7 +4756,7 @@ async function postCloudRemoteHeartbeat(state) {
4599
4756
  await fetchRemoteControlJson(heartbeatUrl, {
4600
4757
  method: 'POST',
4601
4758
  headers: { 'Content-Type': 'application/json' },
4602
- body: JSON.stringify({ cwd: state.cwd, version: CLI_VERSION })
4759
+ body: JSON.stringify({ cwd: state.cwd, version: CLI_VERSION, ...extra })
4603
4760
  }, 12000);
4604
4761
  }
4605
4762
 
@@ -4620,6 +4777,40 @@ async function postCloudRemoteResult(state, commandId, result) {
4620
4777
  }, 35000);
4621
4778
  }
4622
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
+
4623
4814
  async function pollCloudRemoteControlLoop(opts, state) {
4624
4815
  let lastHeartbeatAt = 0;
4625
4816
  while (state.active) {
@@ -4631,6 +4822,17 @@ async function pollCloudRemoteControlLoop(opts, state) {
4631
4822
  { agentToken: state.agentToken, after: state.lastCommandId || 0 }
4632
4823
  );
4633
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
+ }
4634
4836
  const commands = Array.isArray(data.commands) ? data.commands : [];
4635
4837
  state.lastSeenAt = Date.now();
4636
4838
  state.errorCount = 0;
@@ -4641,7 +4843,10 @@ async function pollCloudRemoteControlLoop(opts, state) {
4641
4843
  if (commandId > (state.lastCommandId || 0)) state.lastCommandId = commandId;
4642
4844
  const text = String(command?.text || command?.prompt || '').trim();
4643
4845
  if (!text) continue;
4846
+ state.cancelRequested = false;
4847
+ state.cancelCommandId = null;
4644
4848
  state.busy = true;
4849
+ opts._remoteControlExecutionState = state;
4645
4850
  let result;
4646
4851
  try {
4647
4852
  result = await runRemoteControlPrompt(opts, text);
@@ -4649,10 +4854,23 @@ async function pollCloudRemoteControlLoop(opts, state) {
4649
4854
  result = { ok: false, output: err.stack || err.message || String(err) };
4650
4855
  } finally {
4651
4856
  state.busy = false;
4857
+ delete opts._remoteControlExecutionState;
4858
+ }
4859
+ if (state.cancelRequested && result.ok) {
4860
+ result = { ok: false, output: 'Canceled by remote request.' };
4652
4861
  }
4653
4862
  await postCloudRemoteResult(state, commandId, result);
4863
+ if (state.cancelRequested) {
4864
+ state.cancelRequested = false;
4865
+ state.cancelCommandId = null;
4866
+ }
4654
4867
  }
4655
4868
 
4869
+ if (!commands.length && state.cancelRequested && !state.busy) {
4870
+ await postCloudRemoteHeartbeat(state, { cancelAck: true });
4871
+ state.cancelRequested = false;
4872
+ state.cancelCommandId = null;
4873
+ }
4656
4874
  if (!commands.length && Date.now() - lastHeartbeatAt > 10000) {
4657
4875
  lastHeartbeatAt = Date.now();
4658
4876
  await postCloudRemoteHeartbeat(state);
@@ -4683,6 +4901,8 @@ async function startCloudRemoteControlSession(opts, overrides = {}) {
4683
4901
 
4684
4902
  const baseUrl = getRemoteControlRelayBase(opts, overrides);
4685
4903
  const name = getRemoteControlDisplayName(opts, overrides);
4904
+ const permission = getRemoteControlPermissionMode(opts, overrides);
4905
+ opts.remoteControlPermission = permission;
4686
4906
  const payload = {
4687
4907
  name,
4688
4908
  cwd: opts.cwd,
@@ -4690,7 +4910,8 @@ async function startCloudRemoteControlSession(opts, overrides = {}) {
4690
4910
  machine: os.hostname(),
4691
4911
  platform: process.platform,
4692
4912
  node: process.version,
4693
- mode: opts.agent ? 'agent' : 'chat'
4913
+ mode: opts.agent ? 'agent' : 'chat',
4914
+ permission
4694
4915
  };
4695
4916
  const data = await fetchRemoteControlJson(`${baseUrl}/api/bortex-code/remote/register`, {
4696
4917
  method: 'POST',
@@ -4712,11 +4933,13 @@ async function startCloudRemoteControlSession(opts, overrides = {}) {
4712
4933
  agentToken,
4713
4934
  clientToken: data.clientToken || '',
4714
4935
  name,
4936
+ permission,
4715
4937
  cwd: opts.cwd,
4716
4938
  url: data.url || `${baseUrl}/bortex-code/remote?session=${encodeURIComponent(sessionId)}`,
4717
4939
  pollUrl: data.pollUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/poll`,
4718
4940
  resultUrl: data.resultUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/result`,
4719
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`,
4720
4943
  lastCommandId: 0,
4721
4944
  startedAt: Date.now(),
4722
4945
  busy: false,
@@ -4729,9 +4952,13 @@ async function startCloudRemoteControlSession(opts, overrides = {}) {
4729
4952
  state.pollPromise = pollCloudRemoteControlLoop(opts, state).catch((err) => {
4730
4953
  if (state.active) console.error(`Cloud Remote Control stopped unexpectedly: ${err.message || String(err)}`);
4731
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
+ });
4732
4958
 
4733
4959
  console.log(`Cloud Remote Control active: ${state.name}`);
4734
4960
  console.log(`URL: ${state.url}`);
4961
+ console.log(`Permission: ${state.permission}`);
4735
4962
  console.log('No inbound port required. Keep this process running.');
4736
4963
  console.log('Security: keep this tokenized URL private. Anyone with the URL can control this Bortex Code session.');
4737
4964
  return state;
@@ -4755,6 +4982,7 @@ function parseRemoteControlSlashArgs(rest = [], opts = {}) {
4755
4982
  host: opts.remoteControlHost || '127.0.0.1',
4756
4983
  port: Number(opts.remoteControlPort || 0) || 0,
4757
4984
  relayUrl: opts.remoteControlRelayUrl || '',
4985
+ permission: opts.remoteControlPermission || 'balanced',
4758
4986
  cloud: false
4759
4987
  };
4760
4988
  const nameParts = [];
@@ -4785,6 +5013,19 @@ function parseRemoteControlSlashArgs(rest = [], opts = {}) {
4785
5013
  out.relayUrl = a.slice('--remote-relay='.length);
4786
5014
  continue;
4787
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
+ }
4788
5029
  if ((a === '--host' || a === '--remote-host') && rest[i + 1]) {
4789
5030
  out.host = String(rest[i + 1]);
4790
5031
  i += 1;
@@ -7605,20 +7846,21 @@ async function main() {
7605
7846
  }
7606
7847
 
7607
7848
  if (opts.remoteControlServerMode) {
7849
+ let remoteState = null;
7608
7850
  if (opts.remoteControlCloud) {
7609
- await startCloudRemoteControlSession(opts, {
7851
+ remoteState = await startCloudRemoteControlSession(opts, {
7610
7852
  name: opts.remoteControlName,
7611
7853
  relayUrl: opts.remoteControlRelayUrl
7612
7854
  });
7613
7855
  } else {
7614
- await startRemoteControlServer(opts, {
7856
+ remoteState = await startRemoteControlServer(opts, {
7615
7857
  name: opts.remoteControlName,
7616
7858
  host: opts.remoteControlHost,
7617
7859
  port: opts.remoteControlPort
7618
7860
  });
7619
7861
  }
7620
7862
  console.log('Server mode: keep this process running. Press Ctrl+C to stop Remote Control.');
7621
- await new Promise((resolve) => {
7863
+ const signalPromise = new Promise((resolve) => {
7622
7864
  let stopping = false;
7623
7865
  const stop = async () => {
7624
7866
  if (stopping) return;
@@ -7634,6 +7876,17 @@ async function main() {
7634
7876
  process.once('SIGINT', stop);
7635
7877
  process.once('SIGTERM', stop);
7636
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
+ }
7637
7890
  return;
7638
7891
  }
7639
7892
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bortexcode",
3
- "version": "1.3.0",
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",