bortexcode 1.3.0 → 1.5.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 +19 -0
  2. package/bin/bortex.js +426 -43
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -94,14 +94,30 @@ 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
+
110
+ Remote output streams live for shell commands and CLI output: stdout/stderr
111
+ chunks appear in the browser while the command is still running, then the final
112
+ result replaces the streaming buffer when the command completes.
113
+
99
114
  Inside the REPL:
100
115
 
101
116
  ```text
102
117
  /remote-control My Project
103
118
  /remote-control --lan
104
119
  /remote-control --cloud
120
+ /remote-control --cloud --permission full
105
121
  /remote-control stop
106
122
  ```
107
123
 
@@ -129,6 +145,8 @@ Inside the REPL:
129
145
  Use bortex.site relay instead of opening a local port
130
146
  --remote-relay <url>
131
147
  Remote relay base URL
148
+ --remote-permission <mode>
149
+ Remote permissions: read-only, balanced, full
132
150
  --remote-lan Bind remote control to 0.0.0.0 for LAN/mobile access
133
151
  --remote-host <host>, --remote-port <port>
134
152
  Remote control bind address
@@ -156,4 +174,5 @@ BORTEX_NO_UPDATE_CHECK=1
156
174
  BORTEX_REMOTE_HOST
157
175
  BORTEX_REMOTE_PORT
158
176
  BORTEX_REMOTE_RELAY_URL
177
+ BORTEX_REMOTE_PERMISSION
159
178
  ```
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,45 @@ function runChild(command, args, { cwd, shell = false } = {}) {
3941
3981
  });
3942
3982
  let stdout = '';
3943
3983
  let stderr = '';
3944
- child.stdout.on('data', (c) => { stdout += String(c || ''); });
3945
- 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 }));
3984
+ let settled = false;
3985
+ let canceled = false;
3986
+ const emitChunk = (stream, text) => {
3987
+ try {
3988
+ if (cancelState && typeof cancelState.onChunk === 'function') {
3989
+ cancelState.onChunk(stream, text);
3990
+ }
3991
+ } catch (_err) {}
3992
+ };
3993
+ const finish = (result) => {
3994
+ if (settled) return;
3995
+ settled = true;
3996
+ if (cancelTimer) clearInterval(cancelTimer);
3997
+ resolve(result);
3998
+ };
3999
+ const cancelTimer = cancelState ? setInterval(() => {
4000
+ if (cancelState.cancelRequested || cancelState.revoked || cancelState.active === false) {
4001
+ canceled = true;
4002
+ terminateChildProcessTree(child);
4003
+ }
4004
+ }, 250) : null;
4005
+ if (cancelTimer && typeof cancelTimer.unref === 'function') cancelTimer.unref();
4006
+ child.stdout.on('data', (c) => {
4007
+ const text = String(c || '');
4008
+ stdout += text;
4009
+ emitChunk('stdout', text);
4010
+ });
4011
+ child.stderr.on('data', (c) => {
4012
+ const text = String(c || '');
4013
+ stderr += text;
4014
+ emitChunk('stderr', text);
4015
+ });
4016
+ child.on('error', (err) => finish({ ok: false, code: null, stdout, stderr: err.message }));
4017
+ child.on('close', (code) => {
4018
+ const finalStderr = canceled
4019
+ ? `${stderr || ''}${stderr && !stderr.endsWith('\n') ? '\n' : ''}Canceled by remote request.`
4020
+ : stderr;
4021
+ finish({ ok: !canceled && code === 0, code, stdout, stderr: finalStderr });
4022
+ });
3948
4023
  });
3949
4024
  }
3950
4025
 
@@ -4303,10 +4378,12 @@ function buildRemoteControlHtml(state) {
4303
4378
  const statusEl = document.getElementById('status');
4304
4379
  const promptEl = document.getElementById('prompt');
4305
4380
  const sendEl = document.getElementById('send');
4306
- let lastCount = -1;
4381
+ let lastSignature = '';
4307
4382
  function renderLog(items) {
4308
- if (!Array.isArray(items) || items.length === lastCount) return;
4309
- lastCount = items.length;
4383
+ if (!Array.isArray(items)) return;
4384
+ const signature = items.map((item, index) => String(item.id || index) + ':' + String(item.ts || '') + ':' + String(item.text || '').length).join('|');
4385
+ if (signature === lastSignature) return;
4386
+ lastSignature = signature;
4310
4387
  logEl.innerHTML = items.map((item) => {
4311
4388
  const role = String(item.role || 'system');
4312
4389
  const text = String(item.text || '');
@@ -4352,17 +4429,21 @@ function buildRemoteControlHtml(state) {
4352
4429
  </html>`;
4353
4430
  }
4354
4431
 
4355
- async function captureRemoteControlOutput(fn) {
4432
+ async function captureRemoteControlOutput(fn, onChunk) {
4356
4433
  let stdout = '';
4357
4434
  let stderr = '';
4358
4435
  const originalStdoutWrite = process.stdout.write;
4359
4436
  const originalStderrWrite = process.stderr.write;
4360
4437
  process.stdout.write = function patchedStdoutWrite(chunk, encoding, cb) {
4361
- stdout += Buffer.isBuffer(chunk) ? chunk.toString(typeof encoding === 'string' ? encoding : 'utf8') : String(chunk ?? '');
4438
+ const text = Buffer.isBuffer(chunk) ? chunk.toString(typeof encoding === 'string' ? encoding : 'utf8') : String(chunk ?? '');
4439
+ stdout += text;
4440
+ try { if (typeof onChunk === 'function') onChunk('stdout', text); } catch (_err) {}
4362
4441
  return originalStdoutWrite.apply(process.stdout, arguments);
4363
4442
  };
4364
4443
  process.stderr.write = function patchedStderrWrite(chunk, encoding, cb) {
4365
- stderr += Buffer.isBuffer(chunk) ? chunk.toString(typeof encoding === 'string' ? encoding : 'utf8') : String(chunk ?? '');
4444
+ const text = Buffer.isBuffer(chunk) ? chunk.toString(typeof encoding === 'string' ? encoding : 'utf8') : String(chunk ?? '');
4445
+ stderr += text;
4446
+ try { if (typeof onChunk === 'function') onChunk('stderr', text); } catch (_err) {}
4366
4447
  return originalStderrWrite.apply(process.stderr, arguments);
4367
4448
  };
4368
4449
  try {
@@ -4374,32 +4455,56 @@ async function captureRemoteControlOutput(fn) {
4374
4455
  return { stdout, stderr };
4375
4456
  }
4376
4457
 
4377
- async function runRemoteControlPrompt(opts, text) {
4458
+ async function runRemoteControlPrompt(opts, text, onChunk) {
4378
4459
  const line = String(text || '').trim();
4379
4460
  if (!line) return { ok: false, output: 'Empty prompt.' };
4461
+ const permission = assessRemoteControlPromptPermission(opts, line);
4462
+ if (!permission.ok) {
4463
+ return {
4464
+ ok: false,
4465
+ output: `Remote command blocked (${permission.mode}): ${permission.reason}.\nRestart with --remote-permission full only for a trusted session.`
4466
+ };
4467
+ }
4380
4468
  pushCliHistory(opts, `[remote] ${line}`);
4381
4469
  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
- });
4470
+ const previousRemoteExecution = ACTIVE_REMOTE_CONTROL_EXECUTION_STATE;
4471
+ ACTIVE_REMOTE_CONTROL_EXECUTION_STATE = {
4472
+ ...(opts._remoteControlExecutionState || {}),
4473
+ onChunk
4474
+ };
4475
+ let captured;
4476
+ try {
4477
+ captured = await captureRemoteControlOutput(async () => {
4478
+ if (line === '/exit' || line === '/quit') {
4479
+ console.log('Remote clients cannot exit the local terminal process. Stop it locally with Ctrl+C.');
4480
+ return;
4481
+ }
4482
+ const localResult = await handleLocalCommand(opts, line);
4483
+ if (localResult.handled) return;
4484
+ const localIntent = classifyLocalPromptIntent(line);
4485
+ if (localIntent?.command) {
4486
+ const intentPermission = assessRemoteControlPromptPermission(opts, localIntent.command);
4487
+ if (!intentPermission.ok) {
4488
+ console.log(`Remote inferred command blocked (${intentPermission.mode}): ${intentPermission.reason}.`);
4489
+ return;
4490
+ }
4491
+ const intentResult = await handleLocalCommand(opts, localIntent.command);
4492
+ if (intentResult.handled) return;
4493
+ }
4494
+ if (!permission.skipNaturalActions) {
4495
+ const naturalFileAction = await runNaturalLocalFileAction(opts, line);
4496
+ if (naturalFileAction.handled) return;
4497
+ }
4498
+ if (opts.offline) {
4499
+ console.log('Offline mode: use slash commands and local tools from this remote session.');
4500
+ return;
4501
+ }
4502
+ const data = await askServer(opts, line);
4503
+ printResponse(opts, data);
4504
+ }, onChunk);
4505
+ } finally {
4506
+ ACTIVE_REMOTE_CONTROL_EXECUTION_STATE = previousRemoteExecution;
4507
+ }
4403
4508
  const output = `${captured.stdout || ''}${captured.stderr || ''}`.trim();
4404
4509
  return { ok: true, output: output || '(no output)' };
4405
4510
  }
@@ -4412,6 +4517,7 @@ async function startRemoteControlServer(opts, overrides = {}) {
4412
4517
 
4413
4518
  const http = require('http');
4414
4519
  const crypto = require('crypto');
4520
+ opts.remoteControlPermission = getRemoteControlPermissionMode(opts, overrides);
4415
4521
  const host = String(overrides.host || opts.remoteControlHost || '127.0.0.1').trim() || '127.0.0.1';
4416
4522
  const port = Math.max(0, Math.min(65535, Number(overrides.port ?? opts.remoteControlPort ?? 0) || 0));
4417
4523
  const token = crypto.randomBytes(24).toString('hex');
@@ -4424,10 +4530,37 @@ async function startRemoteControlServer(opts, overrides = {}) {
4424
4530
  cwd: opts.cwd,
4425
4531
  startedAt: Date.now(),
4426
4532
  busy: false,
4427
- log: []
4533
+ log: [],
4534
+ nextLogId: 1
4428
4535
  };
4429
4536
  const appendLog = (role, text) => {
4430
- state.log.push({ role, text: String(text || ''), ts: Date.now() });
4537
+ state.log.push({ id: state.nextLogId++, role, text: String(text || ''), ts: Date.now() });
4538
+ if (state.log.length > 200) state.log.splice(0, state.log.length - 200);
4539
+ };
4540
+ const appendStreamChunk = (streamKey, text, commandId) => {
4541
+ const chunk = String(text || '');
4542
+ if (!chunk) return;
4543
+ let item = null;
4544
+ for (let i = state.log.length - 1; i >= 0; i -= 1) {
4545
+ const candidate = state.log[i];
4546
+ if (candidate?.streaming && candidate.commandId === commandId) {
4547
+ item = candidate;
4548
+ break;
4549
+ }
4550
+ }
4551
+ if (!item) {
4552
+ item = {
4553
+ id: state.nextLogId++,
4554
+ role: streamKey === 'stderr' ? 'system' : 'assistant',
4555
+ text: '',
4556
+ ts: Date.now(),
4557
+ streaming: true,
4558
+ commandId
4559
+ };
4560
+ state.log.push(item);
4561
+ }
4562
+ item.text = `${item.text || ''}${chunk}`.slice(-20000);
4563
+ item.ts = Date.now();
4431
4564
  if (state.log.length > 200) state.log.splice(0, state.log.length - 200);
4432
4565
  };
4433
4566
  appendLog('system', `Remote Control started for ${state.cwd}`);
@@ -4480,8 +4613,17 @@ async function startRemoteControlServer(opts, overrides = {}) {
4480
4613
  const text = String(payload.text || payload.prompt || '').trim();
4481
4614
  if (!text) return sendJson(res, 400, { ok: false, error: 'empty prompt' });
4482
4615
  appendLog('user', text);
4483
- const result = await runRemoteControlPrompt(opts, text);
4484
- appendLog('assistant', result.output || '(no output)');
4616
+ const commandId = state.nextLogId;
4617
+ const result = await runRemoteControlPrompt(opts, text, (stream, chunk) => appendStreamChunk(stream, chunk, commandId));
4618
+ const streamLog = state.log.find((item) => item?.streaming && item.commandId === commandId);
4619
+ if (streamLog) {
4620
+ streamLog.streaming = false;
4621
+ streamLog.role = result.ok ? 'assistant' : 'system';
4622
+ streamLog.text = result.output || streamLog.text || '(no output)';
4623
+ streamLog.ts = Date.now();
4624
+ } else {
4625
+ appendLog('assistant', result.output || '(no output)');
4626
+ }
4485
4627
  return sendJson(res, result.ok ? 200 : 500, { ok: !!result.ok, output: result.output || '' });
4486
4628
  } catch (err) {
4487
4629
  appendLog('system', `Error: ${err.message}`);
@@ -4545,6 +4687,81 @@ function getRemoteControlRelayBase(opts, overrides = {}) {
4545
4687
  return String(overrides.relayUrl || opts.remoteControlRelayUrl || opts.url || 'https://bortex.site').replace(/\/+$/, '');
4546
4688
  }
4547
4689
 
4690
+ function normalizeRemoteControlPermissionMode(value) {
4691
+ const v = String(value || 'balanced').trim().toLowerCase();
4692
+ if (['full', 'trusted', 'unsafe', 'yolo'].includes(v)) return 'full';
4693
+ if (['read-only', 'readonly', 'read', 'safe', 'ro'].includes(v)) return 'read-only';
4694
+ return 'balanced';
4695
+ }
4696
+
4697
+ function getRemoteControlPermissionMode(opts, overrides = {}) {
4698
+ return normalizeRemoteControlPermissionMode(overrides.permission || opts.remoteControlPermission || 'balanced');
4699
+ }
4700
+
4701
+ function isReadOnlyRemoteSlashCommand(line) {
4702
+ const [cmd, ...rest] = parseWords(line);
4703
+ const lc = String(cmd || '').toLowerCase();
4704
+ if (!lc.startsWith('/')) return false;
4705
+ if ([
4706
+ '/help', '/commands', '/menu', '/status', '/pwd', '/ls', '/tree', '/read',
4707
+ '/diff', '/hunks', '/show-hunk', '/ssh-status', '/sys-status',
4708
+ '/process-status', '/port-status', '/history'
4709
+ ].includes(lc)) return true;
4710
+ if (lc === '/llm-config') {
4711
+ const sub = String(rest[0] || 'show').toLowerCase();
4712
+ return !sub || ['show', 'status'].includes(sub);
4713
+ }
4714
+ if (lc === '/git') {
4715
+ const sub = String(rest[0] || '').toLowerCase();
4716
+ return ['status', 'diff', 'log', 'show', 'branch', 'rev-parse', 'remote'].includes(sub);
4717
+ }
4718
+ return false;
4719
+ }
4720
+
4721
+ function isBalancedRemoteShellCommand(cmdText) {
4722
+ const text = String(cmdText || '').trim();
4723
+ const lower = text.toLowerCase();
4724
+ if (!text) return false;
4725
+ if (/[;&|`$<>]/.test(text)) return false;
4726
+ 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;
4727
+ 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;
4728
+ if (/^(npm|pnpm|yarn)\s+(test|run\s+(test|lint|build|check|typecheck)|exec\s+tsc)\b/i.test(text)) return true;
4729
+ if (/^node\s+--check\s+\S+/i.test(text)) return true;
4730
+ if (/^(git\s+(status|diff|log|show|branch|rev-parse)|rg|grep|findstr|ls|dir|pwd|cat|type)\b/i.test(text)) return true;
4731
+ return false;
4732
+ }
4733
+
4734
+ function isBalancedRemoteSlashCommand(line) {
4735
+ if (isReadOnlyRemoteSlashCommand(line)) return true;
4736
+ const [cmd, ...rest] = parseWords(line);
4737
+ const lc = String(cmd || '').toLowerCase();
4738
+ if (lc === '/sh') {
4739
+ const cmdText = line.slice(line.indexOf('/sh') + 3).trim();
4740
+ return isBalancedRemoteShellCommand(cmdText);
4741
+ }
4742
+ if (lc === '/agent') return true;
4743
+ if (lc === '/ux') return true;
4744
+ if (lc === '/remote-control' || lc === '/remote' || lc === '/rc') return true;
4745
+ if (lc === '/agent-run') {
4746
+ return rest.some((x) => String(x).toLowerCase() === '--dry');
4747
+ }
4748
+ return false;
4749
+ }
4750
+
4751
+ function assessRemoteControlPromptPermission(opts, line) {
4752
+ const mode = getRemoteControlPermissionMode(opts);
4753
+ const text = String(line || '').trim();
4754
+ if (!text) return { ok: false, mode, reason: 'empty prompt' };
4755
+ if (mode === 'full') return { ok: true, mode };
4756
+ if (!text.startsWith('/')) return { ok: true, mode, skipNaturalActions: true };
4757
+ if (mode === 'read-only') {
4758
+ if (isReadOnlyRemoteSlashCommand(text)) return { ok: true, mode, skipNaturalActions: true };
4759
+ return { ok: false, mode, reason: 'read-only mode allows inspection commands only' };
4760
+ }
4761
+ if (isBalancedRemoteSlashCommand(text)) return { ok: true, mode, skipNaturalActions: true };
4762
+ return { ok: false, mode, reason: 'balanced mode blocks mutating or unsafe remote commands' };
4763
+ }
4764
+
4548
4765
  function remoteControlSleep(ms) {
4549
4766
  return new Promise((resolve) => setTimeout(resolve, ms));
4550
4767
  }
@@ -4590,7 +4807,7 @@ async function fetchRemoteControlJson(url, options = {}, timeoutMs = 30000) {
4590
4807
  }
4591
4808
  }
4592
4809
 
4593
- async function postCloudRemoteHeartbeat(state) {
4810
+ async function postCloudRemoteHeartbeat(state, extra = {}) {
4594
4811
  const heartbeatUrl = withRemoteControlQuery(
4595
4812
  state.heartbeatUrl,
4596
4813
  state.baseUrl,
@@ -4599,7 +4816,7 @@ async function postCloudRemoteHeartbeat(state) {
4599
4816
  await fetchRemoteControlJson(heartbeatUrl, {
4600
4817
  method: 'POST',
4601
4818
  headers: { 'Content-Type': 'application/json' },
4602
- body: JSON.stringify({ cwd: state.cwd, version: CLI_VERSION })
4819
+ body: JSON.stringify({ cwd: state.cwd, version: CLI_VERSION, ...extra })
4603
4820
  }, 12000);
4604
4821
  }
4605
4822
 
@@ -4620,6 +4837,107 @@ async function postCloudRemoteResult(state, commandId, result) {
4620
4837
  }, 35000);
4621
4838
  }
4622
4839
 
4840
+ async function postCloudRemoteChunk(state, commandId, stream, text) {
4841
+ if (!state?.chunkUrl || !text) return;
4842
+ const chunkUrl = withRemoteControlQuery(
4843
+ state.chunkUrl,
4844
+ state.baseUrl,
4845
+ { agentToken: state.agentToken }
4846
+ );
4847
+ await fetchRemoteControlJson(chunkUrl, {
4848
+ method: 'POST',
4849
+ headers: { 'Content-Type': 'application/json' },
4850
+ body: JSON.stringify({
4851
+ commandId,
4852
+ stream: stream === 'stderr' ? 'stderr' : 'stdout',
4853
+ text: String(text || '')
4854
+ })
4855
+ }, 12000);
4856
+ }
4857
+
4858
+ function createCloudRemoteChunkStreamer(state, commandId) {
4859
+ const queue = [];
4860
+ let timer = null;
4861
+ let flushing = false;
4862
+ const schedule = (ms = 250) => {
4863
+ if (timer) return;
4864
+ timer = setTimeout(() => {
4865
+ timer = null;
4866
+ flush().catch(() => {});
4867
+ }, ms);
4868
+ if (typeof timer.unref === 'function') timer.unref();
4869
+ };
4870
+ const flush = async () => {
4871
+ if (flushing) return;
4872
+ flushing = true;
4873
+ if (timer) {
4874
+ clearTimeout(timer);
4875
+ timer = null;
4876
+ }
4877
+ try {
4878
+ while (queue.length) {
4879
+ const pending = queue.splice(0, queue.length);
4880
+ const grouped = new Map();
4881
+ for (const item of pending) {
4882
+ const stream = item.stream === 'stderr' ? 'stderr' : 'stdout';
4883
+ grouped.set(stream, `${grouped.get(stream) || ''}${item.text || ''}`);
4884
+ }
4885
+ for (const [stream, text] of grouped.entries()) {
4886
+ if (!text) continue;
4887
+ await postCloudRemoteChunk(state, commandId, stream, text).catch((err) => {
4888
+ state.lastChunkError = err.message || String(err);
4889
+ });
4890
+ }
4891
+ }
4892
+ } finally {
4893
+ flushing = false;
4894
+ }
4895
+ };
4896
+ return {
4897
+ push(stream, text) {
4898
+ if (!text || !state.active) return;
4899
+ queue.push({ stream, text: String(text || '') });
4900
+ const queuedSize = queue.reduce((n, item) => n + String(item.text || '').length, 0);
4901
+ schedule(queuedSize > 3000 ? 25 : 250);
4902
+ },
4903
+ flush
4904
+ };
4905
+ }
4906
+
4907
+ async function pollCloudRemoteControlControlLoop(state) {
4908
+ while (state.active) {
4909
+ try {
4910
+ const controlUrl = withRemoteControlQuery(
4911
+ state.controlUrl,
4912
+ state.baseUrl,
4913
+ { agentToken: state.agentToken }
4914
+ );
4915
+ const data = await fetchRemoteControlJson(controlUrl, { method: 'GET' }, 12000);
4916
+ if (data.revoked) {
4917
+ state.revoked = true;
4918
+ state.cancelRequested = true;
4919
+ state.active = false;
4920
+ console.log('Cloud Remote Control revoked by remote client.');
4921
+ break;
4922
+ }
4923
+ if (data.cancelRequested) {
4924
+ state.cancelRequested = true;
4925
+ state.cancelCommandId = data.cancelCommandId || null;
4926
+ }
4927
+ state.controlErrorCount = 0;
4928
+ } catch (err) {
4929
+ state.controlErrorCount = (state.controlErrorCount || 0) + 1;
4930
+ if (/revoked|410/i.test(String(err.message || err))) {
4931
+ state.revoked = true;
4932
+ state.cancelRequested = true;
4933
+ state.active = false;
4934
+ break;
4935
+ }
4936
+ }
4937
+ await remoteControlSleep(Math.min(3000, 1000 + (state.controlErrorCount || 0) * 250));
4938
+ }
4939
+ }
4940
+
4623
4941
  async function pollCloudRemoteControlLoop(opts, state) {
4624
4942
  let lastHeartbeatAt = 0;
4625
4943
  while (state.active) {
@@ -4631,6 +4949,17 @@ async function pollCloudRemoteControlLoop(opts, state) {
4631
4949
  { agentToken: state.agentToken, after: state.lastCommandId || 0 }
4632
4950
  );
4633
4951
  const data = await fetchRemoteControlJson(pollUrl, { method: 'GET' }, 35000);
4952
+ if (data.revoked) {
4953
+ state.revoked = true;
4954
+ state.cancelRequested = true;
4955
+ state.active = false;
4956
+ console.log('Cloud Remote Control revoked by remote client.');
4957
+ break;
4958
+ }
4959
+ if (data.cancelRequested) {
4960
+ state.cancelRequested = true;
4961
+ state.cancelCommandId = data.cancelCommandId || null;
4962
+ }
4634
4963
  const commands = Array.isArray(data.commands) ? data.commands : [];
4635
4964
  state.lastSeenAt = Date.now();
4636
4965
  state.errorCount = 0;
@@ -4641,18 +4970,36 @@ async function pollCloudRemoteControlLoop(opts, state) {
4641
4970
  if (commandId > (state.lastCommandId || 0)) state.lastCommandId = commandId;
4642
4971
  const text = String(command?.text || command?.prompt || '').trim();
4643
4972
  if (!text) continue;
4973
+ state.cancelRequested = false;
4974
+ state.cancelCommandId = null;
4644
4975
  state.busy = true;
4976
+ opts._remoteControlExecutionState = state;
4977
+ const streamer = createCloudRemoteChunkStreamer(state, commandId);
4645
4978
  let result;
4646
4979
  try {
4647
- result = await runRemoteControlPrompt(opts, text);
4980
+ result = await runRemoteControlPrompt(opts, text, (stream, chunk) => streamer.push(stream, chunk));
4648
4981
  } catch (err) {
4649
4982
  result = { ok: false, output: err.stack || err.message || String(err) };
4650
4983
  } finally {
4984
+ await streamer.flush().catch(() => {});
4651
4985
  state.busy = false;
4986
+ delete opts._remoteControlExecutionState;
4987
+ }
4988
+ if (state.cancelRequested && result.ok) {
4989
+ result = { ok: false, output: 'Canceled by remote request.' };
4652
4990
  }
4653
4991
  await postCloudRemoteResult(state, commandId, result);
4992
+ if (state.cancelRequested) {
4993
+ state.cancelRequested = false;
4994
+ state.cancelCommandId = null;
4995
+ }
4654
4996
  }
4655
4997
 
4998
+ if (!commands.length && state.cancelRequested && !state.busy) {
4999
+ await postCloudRemoteHeartbeat(state, { cancelAck: true });
5000
+ state.cancelRequested = false;
5001
+ state.cancelCommandId = null;
5002
+ }
4656
5003
  if (!commands.length && Date.now() - lastHeartbeatAt > 10000) {
4657
5004
  lastHeartbeatAt = Date.now();
4658
5005
  await postCloudRemoteHeartbeat(state);
@@ -4683,6 +5030,8 @@ async function startCloudRemoteControlSession(opts, overrides = {}) {
4683
5030
 
4684
5031
  const baseUrl = getRemoteControlRelayBase(opts, overrides);
4685
5032
  const name = getRemoteControlDisplayName(opts, overrides);
5033
+ const permission = getRemoteControlPermissionMode(opts, overrides);
5034
+ opts.remoteControlPermission = permission;
4686
5035
  const payload = {
4687
5036
  name,
4688
5037
  cwd: opts.cwd,
@@ -4690,7 +5039,8 @@ async function startCloudRemoteControlSession(opts, overrides = {}) {
4690
5039
  machine: os.hostname(),
4691
5040
  platform: process.platform,
4692
5041
  node: process.version,
4693
- mode: opts.agent ? 'agent' : 'chat'
5042
+ mode: opts.agent ? 'agent' : 'chat',
5043
+ permission
4694
5044
  };
4695
5045
  const data = await fetchRemoteControlJson(`${baseUrl}/api/bortex-code/remote/register`, {
4696
5046
  method: 'POST',
@@ -4712,11 +5062,14 @@ async function startCloudRemoteControlSession(opts, overrides = {}) {
4712
5062
  agentToken,
4713
5063
  clientToken: data.clientToken || '',
4714
5064
  name,
5065
+ permission,
4715
5066
  cwd: opts.cwd,
4716
5067
  url: data.url || `${baseUrl}/bortex-code/remote?session=${encodeURIComponent(sessionId)}`,
4717
5068
  pollUrl: data.pollUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/poll`,
4718
5069
  resultUrl: data.resultUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/result`,
5070
+ chunkUrl: data.chunkUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/chunk`,
4719
5071
  heartbeatUrl: data.heartbeatUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/heartbeat`,
5072
+ controlUrl: data.controlUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/control`,
4720
5073
  lastCommandId: 0,
4721
5074
  startedAt: Date.now(),
4722
5075
  busy: false,
@@ -4729,9 +5082,13 @@ async function startCloudRemoteControlSession(opts, overrides = {}) {
4729
5082
  state.pollPromise = pollCloudRemoteControlLoop(opts, state).catch((err) => {
4730
5083
  if (state.active) console.error(`Cloud Remote Control stopped unexpectedly: ${err.message || String(err)}`);
4731
5084
  });
5085
+ state.controlPromise = pollCloudRemoteControlControlLoop(state).catch((err) => {
5086
+ if (state.active) console.error(`Cloud Remote Control control loop stopped unexpectedly: ${err.message || String(err)}`);
5087
+ });
4732
5088
 
4733
5089
  console.log(`Cloud Remote Control active: ${state.name}`);
4734
5090
  console.log(`URL: ${state.url}`);
5091
+ console.log(`Permission: ${state.permission}`);
4735
5092
  console.log('No inbound port required. Keep this process running.');
4736
5093
  console.log('Security: keep this tokenized URL private. Anyone with the URL can control this Bortex Code session.');
4737
5094
  return state;
@@ -4755,6 +5112,7 @@ function parseRemoteControlSlashArgs(rest = [], opts = {}) {
4755
5112
  host: opts.remoteControlHost || '127.0.0.1',
4756
5113
  port: Number(opts.remoteControlPort || 0) || 0,
4757
5114
  relayUrl: opts.remoteControlRelayUrl || '',
5115
+ permission: opts.remoteControlPermission || 'balanced',
4758
5116
  cloud: false
4759
5117
  };
4760
5118
  const nameParts = [];
@@ -4785,6 +5143,19 @@ function parseRemoteControlSlashArgs(rest = [], opts = {}) {
4785
5143
  out.relayUrl = a.slice('--remote-relay='.length);
4786
5144
  continue;
4787
5145
  }
5146
+ if ((a === '--permission' || a === '--remote-permission') && rest[i + 1]) {
5147
+ out.permission = String(rest[i + 1]);
5148
+ i += 1;
5149
+ continue;
5150
+ }
5151
+ if (a.startsWith('--permission=')) {
5152
+ out.permission = a.slice('--permission='.length);
5153
+ continue;
5154
+ }
5155
+ if (a.startsWith('--remote-permission=')) {
5156
+ out.permission = a.slice('--remote-permission='.length);
5157
+ continue;
5158
+ }
4788
5159
  if ((a === '--host' || a === '--remote-host') && rest[i + 1]) {
4789
5160
  out.host = String(rest[i + 1]);
4790
5161
  i += 1;
@@ -7605,20 +7976,21 @@ async function main() {
7605
7976
  }
7606
7977
 
7607
7978
  if (opts.remoteControlServerMode) {
7979
+ let remoteState = null;
7608
7980
  if (opts.remoteControlCloud) {
7609
- await startCloudRemoteControlSession(opts, {
7981
+ remoteState = await startCloudRemoteControlSession(opts, {
7610
7982
  name: opts.remoteControlName,
7611
7983
  relayUrl: opts.remoteControlRelayUrl
7612
7984
  });
7613
7985
  } else {
7614
- await startRemoteControlServer(opts, {
7986
+ remoteState = await startRemoteControlServer(opts, {
7615
7987
  name: opts.remoteControlName,
7616
7988
  host: opts.remoteControlHost,
7617
7989
  port: opts.remoteControlPort
7618
7990
  });
7619
7991
  }
7620
7992
  console.log('Server mode: keep this process running. Press Ctrl+C to stop Remote Control.');
7621
- await new Promise((resolve) => {
7993
+ const signalPromise = new Promise((resolve) => {
7622
7994
  let stopping = false;
7623
7995
  const stop = async () => {
7624
7996
  if (stopping) return;
@@ -7634,6 +8006,17 @@ async function main() {
7634
8006
  process.once('SIGINT', stop);
7635
8007
  process.once('SIGTERM', stop);
7636
8008
  });
8009
+ const remoteDonePromises = [];
8010
+ if (remoteState?.pollPromise) remoteDonePromises.push(remoteState.pollPromise);
8011
+ if (remoteState?.controlPromise) remoteDonePromises.push(remoteState.controlPromise);
8012
+ if (remoteDonePromises.length) {
8013
+ await Promise.race([signalPromise, ...remoteDonePromises]);
8014
+ if (opts.remoteControlCloudSession?.active) {
8015
+ await stopCloudRemoteControlSession(opts);
8016
+ }
8017
+ } else {
8018
+ await signalPromise;
8019
+ }
7637
8020
  return;
7638
8021
  }
7639
8022
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bortexcode",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Bortex Code CLI - AI coding assistant powered by bortex.site",
5
5
  "homepage": "https://bortex.site",
6
6
  "license": "UNLICENSED",