bortexcode 1.5.0 → 1.8.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 +57 -0
  2. package/bin/bortex.js +2204 -89
  3. package/package.json +2 -1
package/bin/bortex.js CHANGED
@@ -4,6 +4,7 @@
4
4
  const fs = require('fs');
5
5
  const os = require('os');
6
6
  const path = require('path');
7
+ const crypto = require('crypto');
7
8
  const readline = require('readline');
8
9
  const { spawn, spawnSync } = require('child_process');
9
10
  const packageMeta = require('../package.json');
@@ -44,12 +45,30 @@ function parseArgs(argv) {
44
45
  spinner: true,
45
46
  progress: false,
46
47
  icons: false
47
- }
48
+ },
49
+ resume: false,
50
+ resumeTarget: '',
51
+ resumeAll: false,
52
+ execMode: false,
53
+ execJson: false,
54
+ execOutput: '',
55
+ execSchema: '',
56
+ execEphemeral: false,
57
+ execMaxTurns: 1,
58
+ permissionProfile: process.env.BORTEX_PERMISSION_PROFILE || process.env.BORTEX_SANDBOX || 'workspace'
48
59
  };
49
60
 
50
61
  const rest = [];
51
62
  for (let i = 0; i < argv.length; i += 1) {
52
63
  const a = argv[i];
64
+ if (a === 'resume') {
65
+ opts.resume = true;
66
+ continue;
67
+ }
68
+ if (a === 'exec') {
69
+ opts.execMode = true;
70
+ continue;
71
+ }
53
72
  if (a === 'remote-control' || a === 'remote') {
54
73
  opts.remoteControlServerMode = true;
55
74
  continue;
@@ -120,6 +139,62 @@ function parseArgs(argv) {
120
139
  opts.offline = true;
121
140
  continue;
122
141
  }
142
+ if (a === '--json') {
143
+ opts.execJson = true;
144
+ continue;
145
+ }
146
+ if ((a === '-o' || a === '--output-last-message' || a === '--output') && argv[i + 1]) {
147
+ opts.execOutput = argv[i + 1];
148
+ i += 1;
149
+ continue;
150
+ }
151
+ if (a.startsWith('--output-last-message=')) {
152
+ opts.execOutput = a.slice('--output-last-message='.length);
153
+ continue;
154
+ }
155
+ if (a.startsWith('--output=')) {
156
+ opts.execOutput = a.slice('--output='.length);
157
+ continue;
158
+ }
159
+ if (a === '--output-schema' && argv[i + 1]) {
160
+ opts.execSchema = argv[i + 1];
161
+ i += 1;
162
+ continue;
163
+ }
164
+ if (a.startsWith('--output-schema=')) {
165
+ opts.execSchema = a.slice('--output-schema='.length);
166
+ continue;
167
+ }
168
+ if (a === '--ephemeral') {
169
+ opts.execEphemeral = true;
170
+ continue;
171
+ }
172
+ if (a === '--max-turns' && argv[i + 1]) {
173
+ opts.execMaxTurns = Math.max(1, Number(argv[i + 1]) || 1);
174
+ i += 1;
175
+ continue;
176
+ }
177
+ if (a.startsWith('--max-turns=')) {
178
+ opts.execMaxTurns = Math.max(1, Number(a.slice('--max-turns='.length)) || 1);
179
+ continue;
180
+ }
181
+ if ((a === '--sandbox' || a === '--permissions' || a === '--permission-profile') && argv[i + 1]) {
182
+ opts.permissionProfile = argv[i + 1];
183
+ i += 1;
184
+ continue;
185
+ }
186
+ if (a.startsWith('--sandbox=')) {
187
+ opts.permissionProfile = a.slice('--sandbox='.length);
188
+ continue;
189
+ }
190
+ if (a.startsWith('--permissions=')) {
191
+ opts.permissionProfile = a.slice('--permissions='.length);
192
+ continue;
193
+ }
194
+ if (a.startsWith('--permission-profile=')) {
195
+ opts.permissionProfile = a.slice('--permission-profile='.length);
196
+ continue;
197
+ }
123
198
  if (a === '--verbose') {
124
199
  opts.ux.verbose = true;
125
200
  continue;
@@ -226,6 +301,24 @@ function parseArgs(argv) {
226
301
  rest.push(a);
227
302
  }
228
303
 
304
+ if (opts.resume && rest.length) {
305
+ const first = String(rest[0] || '').trim();
306
+ if (first === '--last' || first === 'last') {
307
+ opts.resumeTarget = 'last';
308
+ rest.shift();
309
+ } else if (first === '--all') {
310
+ opts.resumeAll = true;
311
+ rest.shift();
312
+ } else if (/^[a-z0-9][a-z0-9._-]{5,}$/i.test(first)) {
313
+ opts.resumeTarget = first;
314
+ rest.shift();
315
+ }
316
+ if (rest[0] === '--all') {
317
+ opts.resumeAll = true;
318
+ rest.shift();
319
+ }
320
+ }
321
+
229
322
  if (rest.length) {
230
323
  opts.prompt = rest.join(' ').trim();
231
324
  }
@@ -240,6 +333,8 @@ function usage() {
240
333
  console.log(` ${cliName} [options] [prompt]`);
241
334
  console.log(` ${cliName} remote-control [--name <title>] [--remote-lan]`);
242
335
  console.log(` ${cliName} remote-control --cloud [--name <title>]`);
336
+ console.log(` ${cliName} exec [--json] [-o file] [--output-schema file] [prompt]`);
337
+ console.log(` ${cliName} resume [--last|<id>] [prompt]`);
243
338
  console.log(` ${cliName} --api-key <apikey>`);
244
339
  console.log(` ${cliName} "write a python function"`);
245
340
  console.log('');
@@ -252,6 +347,14 @@ function usage() {
252
347
  console.log(' -m, --model <name> Force a specific LLM model');
253
348
  console.log(' --api-url <u> Custom LLM endpoint');
254
349
  console.log(' --offline Local mode');
350
+ console.log(' --json Exec mode: emit JSONL events');
351
+ console.log(' -o, --output-last-message <file>');
352
+ console.log(' Exec mode: write final message to file');
353
+ console.log(' --output-schema <file>');
354
+ console.log(' Exec mode: request final JSON matching schema');
355
+ console.log(' --ephemeral Exec mode: do not persist transcript events');
356
+ console.log(' --sandbox <profile>');
357
+ console.log(' Permission profile: read-only, workspace, full');
255
358
  console.log(' --verbose Show request routing details');
256
359
  console.log(' --progress Show server progress events');
257
360
  console.log(' --no-spinner Disable spinner');
@@ -447,15 +550,16 @@ async function fetchJsonWithTimeout(url, timeoutMs = 2500) {
447
550
  }
448
551
 
449
552
  async function resolveLatestCliVersion(opts = {}) {
553
+ const candidates = [];
450
554
  try {
451
555
  const npmLatest = await fetchJsonWithTimeout('https://registry.npmjs.org/bortexcode/latest');
452
556
  if (npmLatest?.version) {
453
- return {
557
+ candidates.push({
454
558
  source: 'npm',
455
559
  version: String(npmLatest.version),
456
560
  installTarget: 'bortexcode',
457
561
  installCommand: 'npm install -g bortexcode'
458
- };
562
+ });
459
563
  }
460
564
  } catch (_err) {
461
565
  // Package may not be published yet. Fall back to bortex.site metadata.
@@ -466,16 +570,18 @@ async function resolveLatestCliVersion(opts = {}) {
466
570
  const siteLatest = await fetchJsonWithTimeout(`${base}/bortex-code/latest.json`);
467
571
  if (siteLatest?.version) {
468
572
  const installTarget = String(siteLatest.installTarget || siteLatest.tarballUrl || `${base}/bortex-code/bortex-code-latest.tgz`);
469
- return {
573
+ candidates.push({
470
574
  source: 'bortex.site',
471
575
  version: String(siteLatest.version),
472
576
  installTarget,
473
577
  installCommand: String(siteLatest.installCommand || `npm install -g ${installTarget}`)
474
- };
578
+ });
475
579
  }
476
580
  } catch (_err) { }
477
581
 
478
- return null;
582
+ if (!candidates.length) return null;
583
+ candidates.sort((a, b) => compareSemver(b.version, a.version));
584
+ return candidates[0];
479
585
  }
480
586
 
481
587
  async function promptYesNo(question, defaultYes = true) {
@@ -982,7 +1088,7 @@ function detectGitUnmergedConflicts(opts) {
982
1088
  }
983
1089
 
984
1090
  function formatModeLine(opts) {
985
- return `Server: ${opts.url} | Mode: ${opts.agent ? 'agent' : 'chat'} | Transport: ${opts.offline ? 'offline-native' : 'server'} | CWD: ${opts.cwd}`;
1091
+ return `Server: ${opts.url} | Mode: ${opts.agent ? 'agent' : 'chat'} | Transport: ${opts.offline ? 'offline-native' : 'server'} | Permissions: ${getEffectivePermissionProfile(opts).name} | CWD: ${opts.cwd}`;
986
1092
  }
987
1093
 
988
1094
  function getCliSessionPath(opts) {
@@ -1083,77 +1189,1665 @@ function sanitizeRunState(runState) {
1083
1189
  };
1084
1190
  }
1085
1191
 
1086
- function sanitizeAgentRunDefaults(value) {
1087
- const raw = value && typeof value === 'object' ? value : {};
1088
- const policy = resolveAgentRunPolicyConfig(raw.policy || 'balanced').name;
1089
- return { policy };
1192
+ function sanitizeAgentRunDefaults(value) {
1193
+ const raw = value && typeof value === 'object' ? value : {};
1194
+ const policy = resolveAgentRunPolicyConfig(raw.policy || 'balanced').name;
1195
+ return { policy };
1196
+ }
1197
+
1198
+ function saveCliWorkspaceState(opts) {
1199
+ const p = getCliSessionPath(opts);
1200
+ const payload = {
1201
+ version: 1,
1202
+ cwd: opts.cwd,
1203
+ updatedAt: Date.now(),
1204
+ todo: sanitizeTodoItems(opts.todo),
1205
+ plan: sanitizePlan(opts.plan),
1206
+ history: sanitizeHistory(opts.commandHistory),
1207
+ artifacts: sanitizeArtifacts(opts.artifacts),
1208
+ runState: sanitizeRunState(opts.runState),
1209
+ agentRunDefaults: sanitizeAgentRunDefaults(opts.agentRunDefaults)
1210
+ };
1211
+ fs.writeFileSync(p, JSON.stringify(payload, null, 2), 'utf8');
1212
+ }
1213
+
1214
+ function loadCliWorkspaceState(opts) {
1215
+ const p = getCliSessionPath(opts);
1216
+ opts.todo = [];
1217
+ opts.plan = null;
1218
+ opts.commandHistory = [];
1219
+ opts.artifacts = [];
1220
+ opts.runState = null;
1221
+ opts.agentRunDefaults = sanitizeAgentRunDefaults(null);
1222
+ if (!fs.existsSync(p)) return { loaded: false, path: p };
1223
+ try {
1224
+ const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
1225
+ opts.todo = sanitizeTodoItems(raw.todo);
1226
+ opts.plan = sanitizePlan(raw.plan);
1227
+ opts.commandHistory = sanitizeHistory(raw.history);
1228
+ opts.artifacts = sanitizeArtifacts(raw.artifacts);
1229
+ opts.runState = sanitizeRunState(raw.runState);
1230
+ opts.agentRunDefaults = sanitizeAgentRunDefaults(raw.agentRunDefaults);
1231
+ return { loaded: true, path: p };
1232
+ } catch (_err) {
1233
+ opts.agentRunDefaults = sanitizeAgentRunDefaults(null);
1234
+ return { loaded: false, path: p, invalid: true };
1235
+ }
1236
+ }
1237
+
1238
+ function pushCliHistory(opts, line) {
1239
+ if (!Array.isArray(opts.commandHistory)) opts.commandHistory = [];
1240
+ const text = String(line || '').trim();
1241
+ if (!text) return;
1242
+ const last = opts.commandHistory[opts.commandHistory.length - 1];
1243
+ if (last && last.text === text) {
1244
+ last.ts = Date.now();
1245
+ return;
1246
+ }
1247
+ opts.commandHistory.push({ text, ts: Date.now() });
1248
+ if (opts.commandHistory.length > 200) {
1249
+ opts.commandHistory = opts.commandHistory.slice(-200);
1250
+ }
1251
+ }
1252
+
1253
+ function recordArtifact(opts, kind, text) {
1254
+ if (!Array.isArray(opts.artifacts)) opts.artifacts = [];
1255
+ opts.artifacts.push({
1256
+ ts: Date.now(),
1257
+ kind: String(kind || 'event'),
1258
+ text: String(text || '')
1259
+ });
1260
+ if (opts.artifacts.length > 300) {
1261
+ opts.artifacts = opts.artifacts.slice(-300);
1262
+ }
1263
+ }
1264
+
1265
+ function getBortexUserDir() {
1266
+ return path.dirname(resolveSharedSettingsPath());
1267
+ }
1268
+
1269
+ function getBortexProjectDir(opts) {
1270
+ return path.join(opts.cwd || process.cwd(), '.bortex');
1271
+ }
1272
+
1273
+ function ensureDirSync(dirPath) {
1274
+ if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true });
1275
+ }
1276
+
1277
+ function safeSlug(value, fallback = 'item') {
1278
+ return String(value || fallback)
1279
+ .toLowerCase()
1280
+ .replace(/[^a-z0-9._-]+/g, '-')
1281
+ .replace(/^-+|-+$/g, '')
1282
+ .slice(0, 80) || fallback;
1283
+ }
1284
+
1285
+ function safeReadJson(filePath, fallback = null) {
1286
+ try {
1287
+ if (!fs.existsSync(filePath)) return fallback;
1288
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
1289
+ } catch (_err) {
1290
+ return fallback;
1291
+ }
1292
+ }
1293
+
1294
+ function safeWriteJson(filePath, payload) {
1295
+ ensureDirSync(path.dirname(filePath));
1296
+ fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8');
1297
+ }
1298
+
1299
+ function parseFrontmatterMarkdown(rawText) {
1300
+ const text = String(rawText || '');
1301
+ const out = { meta: {}, body: text };
1302
+ if (!text.startsWith('---')) return out;
1303
+ const end = text.indexOf('\n---', 3);
1304
+ if (end < 0) return out;
1305
+ const yaml = text.slice(3, end).trim();
1306
+ const body = text.slice(end + 4).replace(/^\r?\n/, '');
1307
+ const meta = {};
1308
+ yaml.split(/\r?\n/).forEach((line) => {
1309
+ const m = line.match(/^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/);
1310
+ if (!m) return;
1311
+ const key = m[1].trim();
1312
+ let value = m[2].trim();
1313
+ value = value.replace(/^['"]|['"]$/g, '');
1314
+ if (value === 'true') meta[key] = true;
1315
+ else if (value === 'false') meta[key] = false;
1316
+ else meta[key] = value;
1317
+ });
1318
+ return { meta, body };
1319
+ }
1320
+
1321
+ function listMarkdownFiles(dirPath) {
1322
+ try {
1323
+ if (!fs.existsSync(dirPath)) return [];
1324
+ return fs.readdirSync(dirPath, { withFileTypes: true })
1325
+ .filter((entry) => entry.isFile() && /\.md$/i.test(entry.name))
1326
+ .map((entry) => path.join(dirPath, entry.name));
1327
+ } catch (_err) {
1328
+ return [];
1329
+ }
1330
+ }
1331
+
1332
+ function listSkillFiles(dirPath) {
1333
+ try {
1334
+ if (!fs.existsSync(dirPath)) return [];
1335
+ return fs.readdirSync(dirPath, { withFileTypes: true })
1336
+ .filter((entry) => entry.isDirectory())
1337
+ .map((entry) => path.join(dirPath, entry.name, 'SKILL.md'))
1338
+ .filter((filePath) => fs.existsSync(filePath));
1339
+ } catch (_err) {
1340
+ return [];
1341
+ }
1342
+ }
1343
+
1344
+ function getPluginStatePath(opts) {
1345
+ return path.join(getBortexProjectDir(opts), 'plugins.json');
1346
+ }
1347
+
1348
+ function loadPluginState(opts) {
1349
+ const raw = safeReadJson(getPluginStatePath(opts), { disabled: {} });
1350
+ return { disabled: raw && typeof raw.disabled === 'object' ? raw.disabled : {} };
1351
+ }
1352
+
1353
+ function savePluginState(opts, state) {
1354
+ safeWriteJson(getPluginStatePath(opts), { disabled: state.disabled || {} });
1355
+ }
1356
+
1357
+ function pluginBaseDirs(opts) {
1358
+ return [
1359
+ { scope: 'user', dir: path.join(getBortexUserDir(), 'plugins') },
1360
+ { scope: 'project', dir: path.join(getBortexProjectDir(opts), 'plugins') }
1361
+ ];
1362
+ }
1363
+
1364
+ function readPluginManifest(pluginDir) {
1365
+ const candidates = [
1366
+ path.join(pluginDir, '.bortex-plugin', 'plugin.json'),
1367
+ path.join(pluginDir, 'plugin.json')
1368
+ ];
1369
+ for (const filePath of candidates) {
1370
+ const manifest = safeReadJson(filePath, null);
1371
+ if (manifest && typeof manifest === 'object') return { manifest, filePath };
1372
+ }
1373
+ return null;
1374
+ }
1375
+
1376
+ function discoverPlugins(opts) {
1377
+ const state = loadPluginState(opts);
1378
+ const rows = [];
1379
+ pluginBaseDirs(opts).forEach((root) => {
1380
+ try {
1381
+ if (!fs.existsSync(root.dir)) return;
1382
+ fs.readdirSync(root.dir, { withFileTypes: true })
1383
+ .filter((entry) => entry.isDirectory())
1384
+ .forEach((entry) => {
1385
+ const dir = path.join(root.dir, entry.name);
1386
+ const loaded = readPluginManifest(dir);
1387
+ if (!loaded) return;
1388
+ const id = safeSlug(loaded.manifest.id || loaded.manifest.name || entry.name);
1389
+ rows.push({
1390
+ id,
1391
+ name: loaded.manifest.name || id,
1392
+ description: loaded.manifest.description || '',
1393
+ version: loaded.manifest.version || '',
1394
+ scope: root.scope,
1395
+ dir,
1396
+ manifestPath: loaded.filePath,
1397
+ manifest: loaded.manifest,
1398
+ enabled: !state.disabled[id]
1399
+ });
1400
+ });
1401
+ } catch (_err) {}
1402
+ });
1403
+ return rows;
1404
+ }
1405
+
1406
+ function getEnabledPluginContentRoots(opts) {
1407
+ return discoverPlugins(opts)
1408
+ .filter((plugin) => plugin.enabled)
1409
+ .map((plugin) => ({
1410
+ scope: `plugin:${plugin.id}`,
1411
+ commands: path.join(plugin.dir, plugin.manifest.commandsDir || 'commands'),
1412
+ skills: path.join(plugin.dir, plugin.manifest.skillsDir || 'skills'),
1413
+ plugin
1414
+ }));
1415
+ }
1416
+
1417
+ function loadCustomSlashCommands(opts) {
1418
+ const roots = [
1419
+ { scope: 'user', commands: path.join(getBortexUserDir(), 'commands'), skills: path.join(getBortexUserDir(), 'skills') },
1420
+ { scope: 'project', commands: path.join(getBortexProjectDir(opts), 'commands'), skills: path.join(getBortexProjectDir(opts), 'skills') }
1421
+ ].concat(getEnabledPluginContentRoots(opts));
1422
+ const rows = [];
1423
+ roots.forEach((root) => {
1424
+ listMarkdownFiles(root.commands).forEach((filePath) => {
1425
+ const parsed = parseFrontmatterMarkdown(fs.readFileSync(filePath, 'utf8'));
1426
+ const name = safeSlug(parsed.meta.name || path.basename(filePath, '.md'));
1427
+ rows.push({
1428
+ scope: root.scope,
1429
+ type: 'command',
1430
+ name,
1431
+ command: `/${name}`,
1432
+ description: String(parsed.meta.description || parsed.body.split(/\r?\n/).find(Boolean) || 'Custom command').slice(0, 160),
1433
+ filePath,
1434
+ meta: parsed.meta,
1435
+ body: parsed.body
1436
+ });
1437
+ });
1438
+ listSkillFiles(root.skills).forEach((filePath) => {
1439
+ const parsed = parseFrontmatterMarkdown(fs.readFileSync(filePath, 'utf8'));
1440
+ const name = safeSlug(parsed.meta.name || path.basename(path.dirname(filePath)));
1441
+ rows.push({
1442
+ scope: root.scope,
1443
+ type: 'skill',
1444
+ name,
1445
+ command: `/${name}`,
1446
+ description: String(parsed.meta.description || parsed.body.split(/\r?\n/).find(Boolean) || 'Custom skill').slice(0, 160),
1447
+ filePath,
1448
+ meta: parsed.meta,
1449
+ body: parsed.body
1450
+ });
1451
+ });
1452
+ });
1453
+ const seen = new Set();
1454
+ return rows.reverse().filter((row) => {
1455
+ if (seen.has(row.command)) return false;
1456
+ seen.add(row.command);
1457
+ return true;
1458
+ }).reverse();
1459
+ }
1460
+
1461
+ function getAllSlashCommandRows(opts = {}) {
1462
+ const custom = loadCustomSlashCommands(opts).map((row) => [row.command, `${row.description} (${row.scope} ${row.type})`]);
1463
+ const existing = new Set(SLASH_COMMANDS.map(([command]) => command.split(/\s+/)[0]));
1464
+ return SLASH_COMMANDS.concat(custom.filter(([command]) => !existing.has(command)));
1465
+ }
1466
+
1467
+ function redactSensitiveText(text) {
1468
+ return String(text || '')
1469
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, 'Bearer [REDACTED]')
1470
+ .replace(/(sk-[A-Za-z0-9_-]{12,})/g, '[REDACTED_API_KEY]')
1471
+ .replace(/(npm_[A-Za-z0-9]{12,})/g, '[REDACTED_NPM_TOKEN]')
1472
+ .replace(/("?(?:password|token|api[_-]?key|secret)"?\s*[:=]\s*)("[^"]+"|'[^']+'|[^\s,}]+)/gi, '$1[REDACTED]');
1473
+ }
1474
+
1475
+ function redactPayload(value) {
1476
+ if (typeof value === 'string') return redactSensitiveText(value);
1477
+ if (Array.isArray(value)) return value.map((item) => redactPayload(item));
1478
+ if (value && typeof value === 'object') {
1479
+ const out = {};
1480
+ Object.entries(value).forEach(([key, item]) => {
1481
+ const sensitiveKey = /password|token|api[_-]?key|secret|authorization|cookie/i.test(key);
1482
+ out[key] = sensitiveKey ? '[REDACTED]' : redactPayload(item);
1483
+ });
1484
+ return out;
1485
+ }
1486
+ return value;
1487
+ }
1488
+
1489
+ function getTranscriptDir(opts) {
1490
+ return path.join(getBortexProjectDir(opts), 'transcripts');
1491
+ }
1492
+
1493
+ function ensureTranscriptId(opts) {
1494
+ if (opts.transcriptId) return opts.transcriptId;
1495
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
1496
+ const slug = safeSlug(path.basename(opts.cwd || process.cwd()), 'session');
1497
+ const rand = crypto.randomBytes(4).toString('hex');
1498
+ opts.transcriptId = `${stamp}-${slug}-${rand}`;
1499
+ return opts.transcriptId;
1500
+ }
1501
+
1502
+ function getCurrentTranscriptPath(opts) {
1503
+ return path.join(getTranscriptDir(opts), `${ensureTranscriptId(opts)}.jsonl`);
1504
+ }
1505
+
1506
+ function appendTranscriptEvent(opts, type, payload = {}) {
1507
+ try {
1508
+ if (opts?.execEphemeral || opts?.ephemeral) return null;
1509
+ const filePath = getCurrentTranscriptPath(opts);
1510
+ ensureDirSync(path.dirname(filePath));
1511
+ const event = {
1512
+ version: 1,
1513
+ id: crypto.randomBytes(6).toString('hex'),
1514
+ ts: Date.now(),
1515
+ type: String(type || 'event'),
1516
+ cwd: opts.cwd || process.cwd(),
1517
+ payload: redactPayload(payload || {})
1518
+ };
1519
+ fs.appendFileSync(filePath, `${JSON.stringify(event)}\n`, 'utf8');
1520
+ return filePath;
1521
+ } catch (_err) {
1522
+ return null;
1523
+ }
1524
+ }
1525
+
1526
+ function listTranscriptFiles(opts, all = false) {
1527
+ const roots = [getTranscriptDir(opts)];
1528
+ if (all) roots.push(path.join(getBortexUserDir(), 'transcripts'));
1529
+ const files = [];
1530
+ roots.forEach((dirPath) => {
1531
+ try {
1532
+ if (!fs.existsSync(dirPath)) return;
1533
+ fs.readdirSync(dirPath)
1534
+ .filter((name) => /\.jsonl$/i.test(name))
1535
+ .forEach((name) => {
1536
+ const filePath = path.join(dirPath, name);
1537
+ const stat = fs.statSync(filePath);
1538
+ files.push({ id: path.basename(name, '.jsonl'), filePath, mtime: stat.mtimeMs, size: stat.size });
1539
+ });
1540
+ } catch (_err) {}
1541
+ });
1542
+ return files.sort((a, b) => b.mtime - a.mtime);
1543
+ }
1544
+
1545
+ function readTranscriptEvents(filePath, maxEvents = 80) {
1546
+ try {
1547
+ const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/).filter(Boolean);
1548
+ return lines.slice(-maxEvents).map((line) => JSON.parse(line));
1549
+ } catch (_err) {
1550
+ return [];
1551
+ }
1552
+ }
1553
+
1554
+ function summarizeTranscriptForResume(filePath) {
1555
+ const events = readTranscriptEvents(filePath, 60);
1556
+ const turns = [];
1557
+ events.forEach((event) => {
1558
+ const type = event.type;
1559
+ const payload = event.payload || {};
1560
+ if (type === 'user') turns.push(`User: ${String(payload.text || '').slice(0, 500)}`);
1561
+ if (type === 'assistant') turns.push(`Assistant: ${String(payload.text || payload.message || '').slice(0, 500)}`);
1562
+ if (type === 'tool') turns.push(`Tool: ${String(payload.command || payload.name || '').slice(0, 240)}`);
1563
+ });
1564
+ return turns.slice(-20).join('\n');
1565
+ }
1566
+
1567
+ function findTranscriptByRef(opts, ref = '', all = false) {
1568
+ const rows = listTranscriptFiles(opts, all);
1569
+ if (!rows.length) return null;
1570
+ const target = String(ref || 'last').trim();
1571
+ if (!target || target === 'last' || target === '--last') return rows[0];
1572
+ return rows.find((row) => row.id === target || row.id.includes(target) || row.filePath === target) || null;
1573
+ }
1574
+
1575
+ function maybeAugmentPromptWithResumeContext(opts, prompt) {
1576
+ if (!opts.resumeContext || opts._resumeContextUsed) return prompt;
1577
+ opts._resumeContextUsed = true;
1578
+ return [
1579
+ 'Previous Bortex Code session context:',
1580
+ opts.resumeContext,
1581
+ '',
1582
+ 'Continue from that context and answer this request:',
1583
+ String(prompt || '')
1584
+ ].join('\n');
1585
+ }
1586
+
1587
+ async function prepareResumeContext(opts) {
1588
+ const found = findTranscriptByRef(opts, opts.resumeTarget || 'last', opts.resumeAll);
1589
+ if (!found) {
1590
+ console.log('No transcript found to resume.');
1591
+ return null;
1592
+ }
1593
+ opts.resumeTranscript = found;
1594
+ opts.resumeContext = summarizeTranscriptForResume(found.filePath);
1595
+ console.log(`Resumed transcript: ${found.id}`);
1596
+ console.log(`Transcript path: ${found.filePath}`);
1597
+ return found;
1598
+ }
1599
+
1600
+ const BUILTIN_SUBAGENTS = [
1601
+ { name: 'reviewer', description: 'Find regressions, risky diffs, missing tests, and code review findings.' },
1602
+ { name: 'security', description: 'Inspect secrets, auth, injection, unsafe shell/network/file operations, and trust boundaries.' },
1603
+ { name: 'tester', description: 'Identify test strategy, missing coverage, flaky paths, and verification commands.' },
1604
+ { name: 'architect', description: 'Assess architecture, module boundaries, maintainability, and migration risks.' },
1605
+ { name: 'fixer', description: 'Propose concrete implementation steps and patches for the current task.' },
1606
+ { name: 'researcher', description: 'Gather local context, docs, dependencies, and unknowns before implementation.' }
1607
+ ];
1608
+
1609
+ function loadCustomSubagents(opts) {
1610
+ const roots = [
1611
+ { scope: 'user', dir: path.join(getBortexUserDir(), 'agents') },
1612
+ { scope: 'project', dir: path.join(getBortexProjectDir(opts), 'agents') }
1613
+ ];
1614
+ const rows = [];
1615
+ roots.forEach((root) => {
1616
+ listMarkdownFiles(root.dir).forEach((filePath) => {
1617
+ const parsed = parseFrontmatterMarkdown(fs.readFileSync(filePath, 'utf8'));
1618
+ rows.push({
1619
+ scope: root.scope,
1620
+ name: safeSlug(parsed.meta.name || path.basename(filePath, '.md')),
1621
+ description: String(parsed.meta.description || parsed.body.split(/\r?\n/).find(Boolean) || 'Custom agent').slice(0, 240),
1622
+ instructions: parsed.body,
1623
+ filePath
1624
+ });
1625
+ });
1626
+ });
1627
+ return rows;
1628
+ }
1629
+
1630
+ function getAllSubagents(opts) {
1631
+ const custom = loadCustomSubagents(opts);
1632
+ const map = new Map();
1633
+ BUILTIN_SUBAGENTS.forEach((a) => map.set(a.name, { ...a, scope: 'builtin', instructions: a.description }));
1634
+ custom.forEach((a) => map.set(a.name, a));
1635
+ return Array.from(map.values());
1636
+ }
1637
+
1638
+ function getMcpConfigPath(opts, scope = 'project') {
1639
+ return scope === 'user'
1640
+ ? path.join(getBortexUserDir(), 'mcp.json')
1641
+ : path.join(getBortexProjectDir(opts), 'mcp.json');
1642
+ }
1643
+
1644
+ function loadMcpConfig(opts, scope = 'project') {
1645
+ const filePath = getMcpConfigPath(opts, scope);
1646
+ const raw = safeReadJson(filePath, { mcpServers: {} });
1647
+ const servers = raw && typeof raw === 'object' && raw.mcpServers && typeof raw.mcpServers === 'object' ? raw.mcpServers : {};
1648
+ return { filePath, mcpServers: servers };
1649
+ }
1650
+
1651
+ function saveMcpConfig(opts, scope, config) {
1652
+ safeWriteJson(getMcpConfigPath(opts, scope), { mcpServers: config.mcpServers || {} });
1653
+ }
1654
+
1655
+ function getMergedMcpServers(opts) {
1656
+ const user = loadMcpConfig(opts, 'user');
1657
+ const project = loadMcpConfig(opts, 'project');
1658
+ const rows = [];
1659
+ Object.entries(user.mcpServers).forEach(([name, server]) => rows.push({ scope: 'user', name, server }));
1660
+ discoverPlugins(opts).filter((plugin) => plugin.enabled).forEach((plugin) => {
1661
+ const servers = plugin.manifest.mcpServers && typeof plugin.manifest.mcpServers === 'object' ? plugin.manifest.mcpServers : {};
1662
+ Object.entries(servers).forEach(([name, server]) => rows.push({ scope: `plugin:${plugin.id}`, name, server }));
1663
+ });
1664
+ Object.entries(project.mcpServers).forEach(([name, server]) => rows.push({ scope: 'project', name, server }));
1665
+ return rows;
1666
+ }
1667
+
1668
+ function resolveMcpServer(opts, name) {
1669
+ const target = String(name || '').trim();
1670
+ return getMergedMcpServers(opts).find((row) => row.name === target) || null;
1671
+ }
1672
+
1673
+ function buildMcpClientInfo() {
1674
+ return { name: 'bortexcode', version: CLI_VERSION };
1675
+ }
1676
+
1677
+ function parseMcpJsonLines(buffer, onMessage) {
1678
+ let remaining = buffer;
1679
+ let nl = remaining.indexOf('\n');
1680
+ while (nl >= 0) {
1681
+ const line = remaining.slice(0, nl).trim();
1682
+ remaining = remaining.slice(nl + 1);
1683
+ if (line) {
1684
+ try { onMessage(JSON.parse(line)); } catch (_err) {}
1685
+ }
1686
+ nl = remaining.indexOf('\n');
1687
+ }
1688
+ return remaining;
1689
+ }
1690
+
1691
+ async function runMcpStdioRequest(opts, row, method, params = {}, options = {}) {
1692
+ const server = row.server || {};
1693
+ if (!server.command) throw new Error(`MCP server "${row.name}" has no command.`);
1694
+ const timeoutMs = Math.max(1000, Number(server.tool_timeout_ms || server.tool_timeout_sec * 1000 || options.timeoutMs || 60000));
1695
+ const env = { ...process.env, ...(server.env || {}) };
1696
+ const child = spawn(String(server.command), Array.isArray(server.args) ? server.args.map(String) : [], {
1697
+ cwd: server.cwd ? resolveCliPath(opts, server.cwd) : (opts.cwd || process.cwd()),
1698
+ env,
1699
+ shell: false,
1700
+ stdio: ['pipe', 'pipe', 'pipe'],
1701
+ windowsHide: true
1702
+ });
1703
+ let nextId = 1;
1704
+ let stdoutBuffer = '';
1705
+ let stderr = '';
1706
+ const pending = new Map();
1707
+ let settled = false;
1708
+
1709
+ const close = () => {
1710
+ if (settled) return;
1711
+ settled = true;
1712
+ try { child.stdin.end(); } catch (_err) {}
1713
+ try { child.kill('SIGTERM'); } catch (_err) {}
1714
+ setTimeout(() => {
1715
+ try { child.kill('SIGKILL'); } catch (_err) {}
1716
+ }, 1000).unref?.();
1717
+ };
1718
+
1719
+ const send = (message) => {
1720
+ child.stdin.write(`${JSON.stringify(message)}\n`);
1721
+ };
1722
+ const request = (reqMethod, reqParams = {}) => new Promise((resolve, reject) => {
1723
+ const id = nextId++;
1724
+ pending.set(id, { resolve, reject });
1725
+ send({ jsonrpc: '2.0', id, method: reqMethod, params: reqParams });
1726
+ });
1727
+
1728
+ child.stdout.on('data', (chunk) => {
1729
+ stdoutBuffer += String(chunk || '');
1730
+ stdoutBuffer = parseMcpJsonLines(stdoutBuffer, (msg) => {
1731
+ if (!msg || typeof msg !== 'object' || msg.id == null) return;
1732
+ const waiter = pending.get(msg.id);
1733
+ if (!waiter) return;
1734
+ pending.delete(msg.id);
1735
+ if (msg.error) waiter.reject(new Error(msg.error.message || JSON.stringify(msg.error)));
1736
+ else waiter.resolve(msg.result);
1737
+ });
1738
+ });
1739
+ child.stderr.on('data', (chunk) => {
1740
+ stderr += String(chunk || '');
1741
+ });
1742
+ child.on('error', (err) => {
1743
+ for (const waiter of pending.values()) waiter.reject(err);
1744
+ pending.clear();
1745
+ });
1746
+ child.on('close', (code) => {
1747
+ const err = new Error(`MCP server "${row.name}" exited with code ${code}${stderr ? `: ${stderr.trim().slice(0, 500)}` : ''}`);
1748
+ for (const waiter of pending.values()) waiter.reject(err);
1749
+ pending.clear();
1750
+ });
1751
+
1752
+ const timer = setTimeout(() => {
1753
+ const err = new Error(`MCP request timed out after ${timeoutMs}ms.`);
1754
+ for (const waiter of pending.values()) waiter.reject(err);
1755
+ pending.clear();
1756
+ close();
1757
+ }, timeoutMs);
1758
+ if (typeof timer.unref === 'function') timer.unref();
1759
+
1760
+ try {
1761
+ await request('initialize', {
1762
+ protocolVersion: server.protocolVersion || '2025-06-18',
1763
+ capabilities: {},
1764
+ clientInfo: buildMcpClientInfo()
1765
+ });
1766
+ send({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} });
1767
+ return await request(method, params);
1768
+ } finally {
1769
+ clearTimeout(timer);
1770
+ close();
1771
+ }
1772
+ }
1773
+
1774
+ function mcpHttpHeaders(server) {
1775
+ const headers = { 'Content-Type': 'application/json', ...(server.headers || {}), ...(server.http_headers || {}) };
1776
+ const bearerEnv = server.bearerTokenEnvVar || server.bearer_token_env_var;
1777
+ if (bearerEnv && process.env[bearerEnv]) headers.Authorization = `Bearer ${process.env[bearerEnv]}`;
1778
+ if (server.env_http_headers && typeof server.env_http_headers === 'object') {
1779
+ Object.entries(server.env_http_headers).forEach(([key, envName]) => {
1780
+ if (process.env[String(envName)]) headers[key] = process.env[String(envName)];
1781
+ });
1782
+ }
1783
+ return headers;
1784
+ }
1785
+
1786
+ async function runMcpHttpRequest(_opts, row, method, params = {}) {
1787
+ const server = row.server || {};
1788
+ if (!server.url) throw new Error(`MCP server "${row.name}" has no URL.`);
1789
+ const body = {
1790
+ jsonrpc: '2.0',
1791
+ id: Date.now(),
1792
+ method,
1793
+ params
1794
+ };
1795
+ const res = await fetch(server.url, {
1796
+ method: 'POST',
1797
+ headers: mcpHttpHeaders(server),
1798
+ body: JSON.stringify(body)
1799
+ });
1800
+ const text = await res.text();
1801
+ if (!res.ok) throw new Error(`MCP HTTP ${res.status}: ${text.slice(0, 1000)}`);
1802
+ const jsonText = text.includes('\ndata:')
1803
+ ? text.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trim()).filter((line) => line && line !== '[DONE]').pop()
1804
+ : text.trim();
1805
+ const payload = JSON.parse(jsonText || '{}');
1806
+ if (payload.error) throw new Error(payload.error.message || JSON.stringify(payload.error));
1807
+ return payload.result ?? payload;
1808
+ }
1809
+
1810
+ async function runMcpRequest(opts, serverName, method, params = {}, options = {}) {
1811
+ const row = resolveMcpServer(opts, serverName);
1812
+ if (!row) throw new Error(`MCP server not found: ${serverName}`);
1813
+ const server = row.server || {};
1814
+ const type = server.type || (server.command ? 'stdio' : 'http');
1815
+ if (type === 'stdio') return runMcpStdioRequest(opts, row, method, params, options);
1816
+ if (type === 'http' || server.url) return runMcpHttpRequest(opts, row, method, params, options);
1817
+ throw new Error(`Unsupported MCP server type for "${serverName}".`);
1818
+ }
1819
+
1820
+ function parseScopeFlag(rest) {
1821
+ const user = rest.includes('--global') || rest.includes('--user');
1822
+ const cleaned = rest.filter((x) => x !== '--global' && x !== '--user' && x !== '--project');
1823
+ return { scope: user ? 'user' : 'project', rest: cleaned };
1824
+ }
1825
+
1826
+ function getPermissionsConfigPath(opts, scope = 'project') {
1827
+ return scope === 'user'
1828
+ ? path.join(getBortexUserDir(), 'permissions.json')
1829
+ : path.join(getBortexProjectDir(opts), 'permissions.json');
1830
+ }
1831
+
1832
+ function loadPermissionsConfig(opts, scope = 'project') {
1833
+ return safeReadJson(getPermissionsConfigPath(opts, scope), { defaultProfile: '', profiles: {} }) || { defaultProfile: '', profiles: {} };
1834
+ }
1835
+
1836
+ function getEffectivePermissionProfile(opts) {
1837
+ const project = loadPermissionsConfig(opts, 'project');
1838
+ const user = loadPermissionsConfig(opts, 'user');
1839
+ const name = safeSlug(opts.permissionProfile || project.defaultProfile || user.defaultProfile || 'workspace');
1840
+ const builtins = {
1841
+ 'read-only': {
1842
+ name: 'read-only',
1843
+ allowWrites: false,
1844
+ allowShell: false,
1845
+ allowNetwork: false,
1846
+ denyPatterns: ['rm -rf', 'del /s', 'format ', 'shutdown', 'reboot']
1847
+ },
1848
+ workspace: {
1849
+ name: 'workspace',
1850
+ allowWrites: true,
1851
+ allowShell: true,
1852
+ allowNetwork: false,
1853
+ denyPatterns: ['rm -rf /', 'rm -rf .', 'format ', 'shutdown', 'reboot']
1854
+ },
1855
+ full: {
1856
+ name: 'full',
1857
+ allowWrites: true,
1858
+ allowShell: true,
1859
+ allowNetwork: true,
1860
+ denyPatterns: []
1861
+ },
1862
+ 'danger-full-access': {
1863
+ name: 'danger-full-access',
1864
+ allowWrites: true,
1865
+ allowShell: true,
1866
+ allowNetwork: true,
1867
+ denyPatterns: []
1868
+ }
1869
+ };
1870
+ const custom = {
1871
+ ...(user.profiles && typeof user.profiles === 'object' ? user.profiles : {}),
1872
+ ...(project.profiles && typeof project.profiles === 'object' ? project.profiles : {})
1873
+ };
1874
+ return { ...(builtins[name] || builtins.workspace), ...(custom[name] || {}), name };
1875
+ }
1876
+
1877
+ function checkLocalPermission(opts, lc, line) {
1878
+ if (lc === '/permissions' || lc === '/help' || lc === '/status' || lc === '/commands') return { allowed: true };
1879
+ const profile = getEffectivePermissionProfile(opts);
1880
+ const text = String(line || '');
1881
+ const writeCommands = new Set(['/write', '/append', '/mkdir']);
1882
+ const shellCommands = new Set(['/sh', '/shell', '/git', '/tool-run', '/agent-run', '/run']);
1883
+ if (!profile.allowWrites && writeCommands.has(lc)) {
1884
+ return { allowed: false, reason: `Permission profile "${profile.name}" does not allow file writes.` };
1885
+ }
1886
+ if (!profile.allowShell && shellCommands.has(lc)) {
1887
+ return { allowed: false, reason: `Permission profile "${profile.name}" does not allow shell/tool execution.` };
1888
+ }
1889
+ const denyPatterns = Array.isArray(profile.denyPatterns) ? profile.denyPatterns : [];
1890
+ const lowered = text.toLowerCase();
1891
+ const blocked = denyPatterns.find((pattern) => lowered.includes(String(pattern).toLowerCase()));
1892
+ if (blocked) return { allowed: false, reason: `Permission profile "${profile.name}" denied pattern: ${blocked}` };
1893
+ return { allowed: true, profile };
1894
+ }
1895
+
1896
+ async function handlePermissionsCommand(opts, rest) {
1897
+ const sub = String(rest[0] || 'show').toLowerCase();
1898
+ if (sub === 'show' || sub === 'status') {
1899
+ const profile = getEffectivePermissionProfile(opts);
1900
+ console.log(JSON.stringify({
1901
+ selected: opts.permissionProfile || 'workspace',
1902
+ effective: profile,
1903
+ projectConfig: getPermissionsConfigPath(opts, 'project'),
1904
+ userConfig: getPermissionsConfigPath(opts, 'user')
1905
+ }, null, 2));
1906
+ return { handled: true, data: profile };
1907
+ }
1908
+ if (sub === 'init') {
1909
+ const filePath = getPermissionsConfigPath(opts, 'project');
1910
+ if (fs.existsSync(filePath)) {
1911
+ console.log(`Permissions file already exists: ${filePath}`);
1912
+ return { handled: true };
1913
+ }
1914
+ safeWriteJson(filePath, {
1915
+ defaultProfile: 'workspace',
1916
+ profiles: {
1917
+ workspace: {
1918
+ allowWrites: true,
1919
+ allowShell: true,
1920
+ allowNetwork: false,
1921
+ denyPatterns: ['rm -rf /', 'format ', 'shutdown', 'reboot']
1922
+ }
1923
+ }
1924
+ });
1925
+ console.log(`Created permissions file: ${filePath}`);
1926
+ return { handled: true, data: { filePath } };
1927
+ }
1928
+ if (sub === 'profile' || sub === 'use') {
1929
+ const name = safeSlug(rest[1] || '');
1930
+ if (!name) {
1931
+ console.log('Usage: /permissions profile <read-only|workspace|full>');
1932
+ return { handled: true };
1933
+ }
1934
+ opts.permissionProfile = name;
1935
+ console.log(`Permission profile: ${name}`);
1936
+ return { handled: true, data: getEffectivePermissionProfile(opts) };
1937
+ }
1938
+ if (sub === 'check') {
1939
+ const line = rest.slice(1).join(' ');
1940
+ const [cmd] = parseWords(line);
1941
+ const result = checkLocalPermission(opts, String(cmd || '').toLowerCase(), line);
1942
+ console.log(JSON.stringify(result, null, 2));
1943
+ return { handled: true, data: result };
1944
+ }
1945
+ console.log('Usage: /permissions show|init|profile <name>|check <slash-command>');
1946
+ return { handled: true };
1947
+ }
1948
+
1949
+ function getHookFiles(opts) {
1950
+ const files = [
1951
+ { scope: 'user', filePath: path.join(getBortexUserDir(), 'hooks.json') },
1952
+ { scope: 'project', filePath: path.join(getBortexProjectDir(opts), 'hooks.json') }
1953
+ ];
1954
+ discoverPlugins(opts).filter((plugin) => plugin.enabled).forEach((plugin) => {
1955
+ const rel = plugin.manifest.hooksFile || path.join('hooks', 'hooks.json');
1956
+ files.push({ scope: `plugin:${plugin.id}`, filePath: path.join(plugin.dir, rel) });
1957
+ });
1958
+ return files;
1959
+ }
1960
+
1961
+ function loadHookEntries(opts, eventName = '') {
1962
+ const rows = [];
1963
+ getHookFiles(opts).forEach((source) => {
1964
+ const raw = safeReadJson(source.filePath, null);
1965
+ if (!raw || typeof raw !== 'object') return;
1966
+ const hooksRoot = raw.hooks && typeof raw.hooks === 'object' ? raw.hooks : raw;
1967
+ Object.entries(hooksRoot).forEach(([event, hooks]) => {
1968
+ if (eventName && event !== eventName) return;
1969
+ const list = Array.isArray(hooks) ? hooks : [];
1970
+ list.forEach((hook, index) => {
1971
+ if (Array.isArray(hook?.hooks)) {
1972
+ hook.hooks.forEach((inner, innerIndex) => rows.push({
1973
+ ...inner,
1974
+ event,
1975
+ matcher: inner.matcher || hook.matcher || '',
1976
+ index,
1977
+ innerIndex,
1978
+ source: source.scope,
1979
+ filePath: source.filePath
1980
+ }));
1981
+ return;
1982
+ }
1983
+ rows.push({ ...hook, event, index, source: source.scope, filePath: source.filePath });
1984
+ });
1985
+ });
1986
+ });
1987
+ return rows.map((row) => ({ ...row, hash: hashHookEntry(row) }));
1988
+ }
1989
+
1990
+ function hashHookEntry(hook) {
1991
+ const stable = {
1992
+ event: hook.event,
1993
+ matcher: hook.matcher || '',
1994
+ type: hook.type || 'command',
1995
+ command: hook.command || '',
1996
+ source: hook.source,
1997
+ filePath: hook.filePath
1998
+ };
1999
+ return crypto.createHash('sha256').update(JSON.stringify(stable)).digest('hex').slice(0, 16);
2000
+ }
2001
+
2002
+ function getHookTrustPath() {
2003
+ return path.join(getBortexUserDir(), 'hooks-trust.json');
2004
+ }
2005
+
2006
+ function loadHookTrust() {
2007
+ const raw = safeReadJson(getHookTrustPath(), { trusted: {}, disabled: {} });
2008
+ return {
2009
+ trusted: raw && typeof raw.trusted === 'object' ? raw.trusted : {},
2010
+ disabled: raw && typeof raw.disabled === 'object' ? raw.disabled : {}
2011
+ };
2012
+ }
2013
+
2014
+ function saveHookTrust(trust) {
2015
+ safeWriteJson(getHookTrustPath(), {
2016
+ trusted: trust.trusted || {},
2017
+ disabled: trust.disabled || {}
2018
+ });
2019
+ }
2020
+
2021
+ function annotateHookTrust(rows) {
2022
+ const trust = loadHookTrust();
2023
+ return rows.map((row) => ({
2024
+ ...row,
2025
+ trusted: !!trust.trusted[row.hash],
2026
+ disabled: !!trust.disabled[row.hash]
2027
+ }));
2028
+ }
2029
+
2030
+ function hookMatches(hook, payload) {
2031
+ const matcher = String(hook.matcher || '').trim();
2032
+ if (!matcher || matcher === '*') return true;
2033
+ const haystack = [
2034
+ payload.tool_name,
2035
+ payload.command,
2036
+ payload.tool_input?.command,
2037
+ payload.prompt
2038
+ ].filter(Boolean).join(' ');
2039
+ if (!haystack) return false;
2040
+ if (matcher.startsWith('/') && matcher.endsWith('/')) {
2041
+ try { return new RegExp(matcher.slice(1, -1)).test(haystack); } catch (_err) { return false; }
2042
+ }
2043
+ return haystack.toLowerCase().includes(matcher.toLowerCase());
2044
+ }
2045
+
2046
+ async function runBortexHooks(opts, eventName, payload = {}, options = {}) {
2047
+ if (opts._runningBortexHooks) return { denied: false, results: [] };
2048
+ const hooks = annotateHookTrust(loadHookEntries(opts, eventName))
2049
+ .filter((hook) => !hook.disabled)
2050
+ .filter((hook) => !(process.env.BORTEX_HOOK_TRUST === '1' && hook.source !== 'user' && !hook.trusted))
2051
+ .filter((hook) => hookMatches(hook, payload));
2052
+ const results = [];
2053
+ if (!hooks.length) return { denied: false, results };
2054
+ opts._runningBortexHooks = true;
2055
+ try {
2056
+ for (const hook of hooks) {
2057
+ if (String(hook.type || 'command') !== 'command' || !hook.command) continue;
2058
+ const hookPayload = {
2059
+ event: eventName,
2060
+ cwd: opts.cwd || process.cwd(),
2061
+ source: hook.source,
2062
+ payload
2063
+ };
2064
+ const res = await runChild(String(hook.command), [], {
2065
+ cwd: opts.cwd,
2066
+ shell: true,
2067
+ env: {
2068
+ BORTEX_HOOK_EVENT: eventName,
2069
+ BORTEX_HOOK_PAYLOAD: redactSensitiveText(JSON.stringify(hookPayload))
2070
+ }
2071
+ });
2072
+ let parsed = null;
2073
+ try { parsed = JSON.parse(String(res.stdout || '').trim()); } catch (_err) {}
2074
+ results.push({ hook, ok: res.ok, stdout: res.stdout, stderr: res.stderr, parsed });
2075
+ const decision = parsed?.permissionDecision || parsed?.hookSpecificOutput?.permissionDecision || parsed?.decision;
2076
+ if (options.decision && ['deny', 'block'].includes(String(decision || '').toLowerCase())) {
2077
+ return {
2078
+ denied: true,
2079
+ reason: parsed?.permissionDecisionReason || parsed?.hookSpecificOutput?.permissionDecisionReason || parsed?.reason || `Denied by ${hook.source} hook`,
2080
+ results
2081
+ };
2082
+ }
2083
+ if (options.decision && ['ask'].includes(String(decision || '').toLowerCase())) {
2084
+ const ok = await confirmDangerous(parsed?.reason || `Hook ${hook.source}:${eventName} asks for approval. Continue?`);
2085
+ if (!ok) return { denied: true, reason: 'Rejected by user after hook ask decision', results };
2086
+ }
2087
+ }
2088
+ } finally {
2089
+ opts._runningBortexHooks = false;
2090
+ }
2091
+ return { denied: false, results };
2092
+ }
2093
+
2094
+ async function renderCustomSlashPrompt(opts, entry, argsText) {
2095
+ let body = String(entry.body || '');
2096
+ body = body
2097
+ .replace(/\$ARGUMENTS/g, argsText)
2098
+ .replace(/\$\{BORTEX_CWD\}/g, opts.cwd || process.cwd())
2099
+ .replace(/\$\{BORTEX_SESSION_ID\}/g, opts.transcriptId || ensureTranscriptId(opts))
2100
+ .replace(/\$\{BORTEX_COMMAND_DIR\}/g, path.dirname(entry.filePath));
2101
+
2102
+ if (entry.meta && entry.meta['allow-shell-injection'] === true) {
2103
+ const lines = [];
2104
+ for (const line of body.split(/\r?\n/)) {
2105
+ const match = line.match(/!\`([^`]+)\`/);
2106
+ if (!match) {
2107
+ lines.push(line);
2108
+ continue;
2109
+ }
2110
+ const res = await runChild(match[1], [], { cwd: opts.cwd, shell: true });
2111
+ const replacement = redactSensitiveText((res.stdout || res.stderr || '').trim()).slice(0, 12000);
2112
+ lines.push(line.replace(match[0], replacement || '(no output)'));
2113
+ }
2114
+ body = lines.join('\n');
2115
+ }
2116
+
2117
+ return [
2118
+ `Run Bortex ${entry.type}: /${entry.name}`,
2119
+ entry.description ? `Purpose: ${entry.description}` : '',
2120
+ argsText ? `Arguments: ${argsText}` : '',
2121
+ '',
2122
+ body
2123
+ ].filter(Boolean).join('\n');
2124
+ }
2125
+
2126
+ async function handleCustomSlashCommand(opts, lc, rest, line) {
2127
+ const entry = loadCustomSlashCommands(opts).find((row) => row.command.toLowerCase() === lc);
2128
+ if (!entry) return false;
2129
+ const argsText = line.slice(line.indexOf(lc) + lc.length).trim();
2130
+ const prompt = await renderCustomSlashPrompt(opts, entry, argsText);
2131
+ recordArtifact(opts, entry.type, `${entry.command} ${argsText}`.trim());
2132
+ appendTranscriptEvent(opts, 'tool', { command: entry.command, type: entry.type, filePath: entry.filePath, args: argsText });
2133
+ if (opts.offline) {
2134
+ console.log(prompt);
2135
+ return true;
2136
+ }
2137
+ const data = await askServer(opts, maybeAugmentPromptWithResumeContext(opts, prompt));
2138
+ printResponse(opts, data);
2139
+ appendTranscriptEvent(opts, 'assistant', { text: data?.message || JSON.stringify(data || {}) });
2140
+ return true;
2141
+ }
2142
+
2143
+ async function handleSkillsCommand(opts, rest) {
2144
+ const sub = String(rest[0] || 'list').toLowerCase();
2145
+ const commands = loadCustomSlashCommands(opts);
2146
+ if (sub === 'list') {
2147
+ const rows = commands.filter((row) => row.type === 'skill' || row.type === 'command');
2148
+ if (!rows.length) {
2149
+ console.log('No custom skills or commands found.');
2150
+ console.log(`Project path: ${path.join(getBortexProjectDir(opts), 'skills')}`);
2151
+ console.log(`User path : ${path.join(getBortexUserDir(), 'skills')}`);
2152
+ return { handled: true, data: [] };
2153
+ }
2154
+ rows.forEach((row) => console.log(`${row.command.padEnd(24)} ${row.description} [${row.scope}/${row.type}]`));
2155
+ return { handled: true, data: rows };
2156
+ }
2157
+ if (sub === 'show') {
2158
+ const name = String(rest[1] || '').replace(/^\//, '').toLowerCase();
2159
+ const row = commands.find((item) => item.name.toLowerCase() === name || item.command.toLowerCase() === `/${name}`);
2160
+ if (!row) {
2161
+ console.log('Skill not found.');
2162
+ return { handled: true };
2163
+ }
2164
+ console.log(`${row.command} (${row.scope}/${row.type})`);
2165
+ console.log(`Path: ${row.filePath}`);
2166
+ console.log('');
2167
+ process.stdout.write(row.body.endsWith('\n') ? row.body : `${row.body}\n`);
2168
+ return { handled: true, data: row };
2169
+ }
2170
+ if (sub === 'new') {
2171
+ const name = safeSlug(rest[1] || '');
2172
+ if (!name) {
2173
+ console.log('Usage: /skills new <name>');
2174
+ return { handled: true };
2175
+ }
2176
+ const dirPath = path.join(getBortexProjectDir(opts), 'skills', name);
2177
+ const filePath = path.join(dirPath, 'SKILL.md');
2178
+ if (fs.existsSync(filePath)) {
2179
+ console.log(`Skill already exists: ${filePath}`);
2180
+ return { handled: true };
2181
+ }
2182
+ ensureDirSync(dirPath);
2183
+ const body = [
2184
+ '---',
2185
+ `description: ${name} workflow for this repository.`,
2186
+ 'disable-model-invocation: true',
2187
+ '---',
2188
+ '',
2189
+ `Run the ${name} workflow for $ARGUMENTS.`,
2190
+ '',
2191
+ '1. Inspect the relevant files.',
2192
+ '2. Make a concise plan.',
2193
+ '3. Execute only the necessary changes.',
2194
+ '4. Verify the result.'
2195
+ ].join('\n');
2196
+ fs.writeFileSync(filePath, body, 'utf8');
2197
+ console.log(`Created skill: ${filePath}`);
2198
+ return { handled: true, data: { filePath } };
2199
+ }
2200
+ console.log('Usage: /skills list|show <name>|new <name>');
2201
+ return { handled: true };
2202
+ }
2203
+
2204
+ async function handlePluginsCommand(opts, rest) {
2205
+ const sub = String(rest[0] || 'list').toLowerCase();
2206
+ if (sub === 'list') {
2207
+ const rows = discoverPlugins(opts);
2208
+ if (!rows.length) {
2209
+ console.log('No plugins installed.');
2210
+ console.log(`Project plugins: ${path.join(getBortexProjectDir(opts), 'plugins')}`);
2211
+ console.log(`User plugins : ${path.join(getBortexUserDir(), 'plugins')}`);
2212
+ return { handled: true, data: [] };
2213
+ }
2214
+ rows.forEach((plugin) => {
2215
+ const state = plugin.enabled ? 'enabled' : 'disabled';
2216
+ console.log(`${plugin.id.padEnd(22)} ${state.padEnd(9)} ${plugin.version || '-'} ${plugin.description || plugin.name} [${plugin.scope}]`);
2217
+ });
2218
+ return { handled: true, data: rows };
2219
+ }
2220
+ if (sub === 'init') {
2221
+ const name = safeSlug(rest[1] || '');
2222
+ if (!name) {
2223
+ console.log('Usage: /plugins init <name>');
2224
+ return { handled: true };
2225
+ }
2226
+ const dir = path.join(getBortexProjectDir(opts), 'plugins', name);
2227
+ const manifestPath = path.join(dir, '.bortex-plugin', 'plugin.json');
2228
+ if (fs.existsSync(manifestPath)) {
2229
+ console.log(`Plugin already exists: ${manifestPath}`);
2230
+ return { handled: true };
2231
+ }
2232
+ ensureDirSync(path.dirname(manifestPath));
2233
+ ensureDirSync(path.join(dir, 'skills', name));
2234
+ ensureDirSync(path.join(dir, 'commands'));
2235
+ ensureDirSync(path.join(dir, 'hooks'));
2236
+ safeWriteJson(manifestPath, {
2237
+ id: name,
2238
+ name,
2239
+ version: '0.1.0',
2240
+ description: `${name} plugin for Bortex Code`,
2241
+ skillsDir: 'skills',
2242
+ commandsDir: 'commands',
2243
+ hooksFile: 'hooks/hooks.json',
2244
+ mcpServers: {}
2245
+ });
2246
+ fs.writeFileSync(path.join(dir, 'skills', name, 'SKILL.md'), [
2247
+ '---',
2248
+ `description: ${name} plugin skill.`,
2249
+ '---',
2250
+ '',
2251
+ `Run the ${name} plugin workflow for $ARGUMENTS.`
2252
+ ].join('\n'), 'utf8');
2253
+ safeWriteJson(path.join(dir, 'hooks', 'hooks.json'), { hooks: {} });
2254
+ console.log(`Created plugin: ${dir}`);
2255
+ return { handled: true, data: { dir, manifestPath } };
2256
+ }
2257
+ if (sub === 'install') {
2258
+ const srcRaw = String(rest[1] || '').trim();
2259
+ if (!srcRaw) {
2260
+ console.log('Usage: /plugins install <local-path> [--global]');
2261
+ return { handled: true };
2262
+ }
2263
+ const parsed = parseScopeFlag(rest.slice(2));
2264
+ const src = resolveCliPath(opts, srcRaw);
2265
+ if (!fs.existsSync(src) || !fs.statSync(src).isDirectory()) {
2266
+ console.log(`Plugin path not found: ${src}`);
2267
+ return { handled: true };
2268
+ }
2269
+ const loaded = readPluginManifest(src);
2270
+ if (!loaded) {
2271
+ console.log('Plugin manifest not found. Expected .bortex-plugin/plugin.json or plugin.json.');
2272
+ return { handled: true };
2273
+ }
2274
+ const id = safeSlug(loaded.manifest.id || loaded.manifest.name || path.basename(src));
2275
+ const dest = path.join(parsed.scope === 'user' ? getBortexUserDir() : getBortexProjectDir(opts), 'plugins', id);
2276
+ if (fs.existsSync(dest)) {
2277
+ console.log(`Plugin already installed: ${dest}`);
2278
+ return { handled: true };
2279
+ }
2280
+ ensureDirSync(path.dirname(dest));
2281
+ fs.cpSync(src, dest, { recursive: true });
2282
+ console.log(`Installed plugin "${id}" to ${parsed.scope}: ${dest}`);
2283
+ return { handled: true, data: { id, dest } };
2284
+ }
2285
+ if (sub === 'enable' || sub === 'disable') {
2286
+ const id = safeSlug(rest[1] || '');
2287
+ if (!id) {
2288
+ console.log(`Usage: /plugins ${sub} <id>`);
2289
+ return { handled: true };
2290
+ }
2291
+ const state = loadPluginState(opts);
2292
+ if (sub === 'disable') state.disabled[id] = { ts: Date.now() };
2293
+ else delete state.disabled[id];
2294
+ savePluginState(opts, state);
2295
+ console.log(`${sub === 'disable' ? 'Disabled' : 'Enabled'} plugin: ${id}`);
2296
+ return { handled: true, data: state };
2297
+ }
2298
+ if (sub === 'show') {
2299
+ const id = safeSlug(rest[1] || '');
2300
+ const row = discoverPlugins(opts).find((plugin) => plugin.id === id);
2301
+ if (!row) {
2302
+ console.log('Plugin not found.');
2303
+ return { handled: true };
2304
+ }
2305
+ console.log(JSON.stringify(row, null, 2));
2306
+ return { handled: true, data: row };
2307
+ }
2308
+ console.log('Usage: /plugins list|show <id>|init <name>|install <path> [--global]|enable <id>|disable <id>');
2309
+ return { handled: true };
2310
+ }
2311
+
2312
+ async function handleBrowserCommand(opts, rest) {
2313
+ const sub = String(rest[0] || 'status').toLowerCase();
2314
+ if (sub === 'setup' || sub === 'install') {
2315
+ const parsed = parseScopeFlag(rest.slice(1));
2316
+ const cfg = loadMcpConfig(opts, parsed.scope);
2317
+ cfg.mcpServers.playwright = {
2318
+ type: 'stdio',
2319
+ command: 'npx',
2320
+ args: ['-y', '@playwright/mcp'],
2321
+ env: {}
2322
+ };
2323
+ saveMcpConfig(opts, parsed.scope, cfg);
2324
+ console.log(`Configured Playwright MCP browser server in ${parsed.scope} config.`);
2325
+ console.log('Use: /mcp tools playwright');
2326
+ return { handled: true, data: cfg.mcpServers.playwright };
2327
+ }
2328
+ if (sub === 'status') {
2329
+ const row = resolveMcpServer(opts, 'playwright');
2330
+ if (!row) {
2331
+ console.log('Playwright MCP is not configured. Run: /browser setup');
2332
+ return { handled: true, data: null };
2333
+ }
2334
+ console.log(JSON.stringify(row, null, 2));
2335
+ return { handled: true, data: row };
2336
+ }
2337
+ console.log('Usage: /browser status|setup [--global]');
2338
+ return { handled: true };
2339
+ }
2340
+
2341
+ async function handleMcpCommand(opts, rest, line) {
2342
+ const sub = String(rest[0] || 'list').toLowerCase();
2343
+ if (sub === 'list' || sub === 'status') {
2344
+ const rows = getMergedMcpServers(opts);
2345
+ if (!rows.length) {
2346
+ console.log('No MCP servers configured.');
2347
+ console.log(`Project config: ${getMcpConfigPath(opts, 'project')}`);
2348
+ console.log(`User config : ${getMcpConfigPath(opts, 'user')}`);
2349
+ return { handled: true, data: [] };
2350
+ }
2351
+ rows.forEach((row) => {
2352
+ const type = row.server.type || (row.server.command ? 'stdio' : 'http');
2353
+ const target = row.server.command || row.server.url || '(missing target)';
2354
+ console.log(`${row.name.padEnd(22)} ${type.padEnd(8)} ${target} [${row.scope}]`);
2355
+ });
2356
+ return { handled: true, data: rows };
2357
+ }
2358
+ if (sub === 'get') {
2359
+ const name = String(rest[1] || '');
2360
+ const row = getMergedMcpServers(opts).find((item) => item.name === name);
2361
+ if (!row) console.log('MCP server not found.');
2362
+ else console.log(JSON.stringify(row, null, 2));
2363
+ return { handled: true, data: row || null };
2364
+ }
2365
+ if (sub === 'remove' || sub === 'rm') {
2366
+ const name = String(rest[1] || '');
2367
+ if (!name) {
2368
+ console.log('Usage: /mcp remove <name> [--global]');
2369
+ return { handled: true };
2370
+ }
2371
+ const parsed = parseScopeFlag(rest.slice(2));
2372
+ const cfg = loadMcpConfig(opts, parsed.scope);
2373
+ delete cfg.mcpServers[name];
2374
+ saveMcpConfig(opts, parsed.scope, cfg);
2375
+ console.log(`Removed MCP server "${name}" from ${parsed.scope} config.`);
2376
+ return { handled: true };
2377
+ }
2378
+ if (sub === 'add') {
2379
+ const name = safeSlug(rest[1] || '');
2380
+ const sep = rest.indexOf('--');
2381
+ if (!name || sep < 0 || sep >= rest.length - 1) {
2382
+ console.log('Usage: /mcp add <name> [--global] -- <command> [args...]');
2383
+ return { handled: true };
2384
+ }
2385
+ const parsed = parseScopeFlag(rest.slice(2, sep));
2386
+ const commandParts = rest.slice(sep + 1);
2387
+ const cfg = loadMcpConfig(opts, parsed.scope);
2388
+ cfg.mcpServers[name] = { type: 'stdio', command: commandParts[0], args: commandParts.slice(1), env: {} };
2389
+ saveMcpConfig(opts, parsed.scope, cfg);
2390
+ console.log(`Added stdio MCP server "${name}" to ${parsed.scope} config.`);
2391
+ return { handled: true, data: cfg.mcpServers[name] };
2392
+ }
2393
+ if (sub === 'add-http') {
2394
+ const name = safeSlug(rest[1] || '');
2395
+ const url = String(rest[2] || '').trim();
2396
+ if (!name || !/^https?:\/\//i.test(url)) {
2397
+ console.log('Usage: /mcp add-http <name> <url> [--global] [--header Key=Value]');
2398
+ return { handled: true };
2399
+ }
2400
+ const parsed = parseScopeFlag(rest.slice(3));
2401
+ const headers = {};
2402
+ for (let i = 0; i < parsed.rest.length; i += 1) {
2403
+ if (parsed.rest[i] === '--header' && parsed.rest[i + 1]) {
2404
+ const raw = parsed.rest[i + 1];
2405
+ const idx = raw.includes('=') ? raw.indexOf('=') : raw.indexOf(':');
2406
+ if (idx > 0) headers[raw.slice(0, idx).trim()] = raw.slice(idx + 1).trim();
2407
+ i += 1;
2408
+ }
2409
+ }
2410
+ const cfg = loadMcpConfig(opts, parsed.scope);
2411
+ cfg.mcpServers[name] = { type: 'http', url, headers };
2412
+ saveMcpConfig(opts, parsed.scope, cfg);
2413
+ console.log(`Added HTTP MCP server "${name}" to ${parsed.scope} config.`);
2414
+ return { handled: true, data: cfg.mcpServers[name] };
2415
+ }
2416
+ if (sub === 'add-json') {
2417
+ const name = safeSlug(rest[1] || '');
2418
+ const jsonText = rest.slice(2).join(' ').trim();
2419
+ if (!name || !jsonText) {
2420
+ console.log('Usage: /mcp add-json <name> <json>');
2421
+ return { handled: true };
2422
+ }
2423
+ let server;
2424
+ try { server = JSON.parse(jsonText); } catch (err) {
2425
+ console.log(`Invalid JSON: ${err.message}`);
2426
+ return { handled: true };
2427
+ }
2428
+ const cfg = loadMcpConfig(opts, 'project');
2429
+ cfg.mcpServers[name] = server;
2430
+ saveMcpConfig(opts, 'project', cfg);
2431
+ console.log(`Added JSON MCP server "${name}" to project config.`);
2432
+ return { handled: true, data: server };
2433
+ }
2434
+ if (sub === 'tools' || sub === 'tool-list') {
2435
+ const name = String(rest[1] || '').trim();
2436
+ if (!name) {
2437
+ console.log('Usage: /mcp tools <server>');
2438
+ return { handled: true };
2439
+ }
2440
+ try {
2441
+ const result = await runMcpRequest(opts, name, 'tools/list', {});
2442
+ const tools = Array.isArray(result?.tools) ? result.tools : [];
2443
+ if (!tools.length) console.log('No tools returned.');
2444
+ tools.forEach((tool) => console.log(`${String(tool.name || '').padEnd(28)} ${tool.description || ''}`));
2445
+ return { handled: true, data: result };
2446
+ } catch (err) {
2447
+ console.log(`MCP tools failed: ${err.message}`);
2448
+ return { handled: true, data: { error: err.message } };
2449
+ }
2450
+ }
2451
+ if (sub === 'call') {
2452
+ const name = String(rest[1] || '').trim();
2453
+ const toolName = String(rest[2] || '').trim();
2454
+ const jsonText = rest.slice(3).join(' ').trim() || '{}';
2455
+ if (!name || !toolName) {
2456
+ console.log('Usage: /mcp call <server> <tool> [json-arguments]');
2457
+ return { handled: true };
2458
+ }
2459
+ let argsPayload;
2460
+ try { argsPayload = JSON.parse(jsonText); } catch (err) {
2461
+ console.log(`Invalid tool arguments JSON: ${err.message}`);
2462
+ return { handled: true };
2463
+ }
2464
+ const hookDecision = await runBortexHooks(opts, 'PreToolUse', {
2465
+ tool_name: `mcp__${name}__${toolName}`,
2466
+ tool_input: argsPayload,
2467
+ command: `/mcp call ${name} ${toolName}`
2468
+ }, { decision: true });
2469
+ if (hookDecision.denied) {
2470
+ console.log(`MCP tool denied by hook: ${hookDecision.reason}`);
2471
+ return { handled: true, data: hookDecision };
2472
+ }
2473
+ try {
2474
+ const result = await runMcpRequest(opts, name, 'tools/call', { name: toolName, arguments: argsPayload });
2475
+ console.log(JSON.stringify(result, null, 2));
2476
+ await runBortexHooks(opts, 'PostToolUse', {
2477
+ tool_name: `mcp__${name}__${toolName}`,
2478
+ tool_input: argsPayload,
2479
+ result
2480
+ });
2481
+ return { handled: true, data: result };
2482
+ } catch (err) {
2483
+ console.log(`MCP call failed: ${err.message}`);
2484
+ await runBortexHooks(opts, 'PostToolUse', {
2485
+ tool_name: `mcp__${name}__${toolName}`,
2486
+ tool_input: argsPayload,
2487
+ error: err.message
2488
+ });
2489
+ return { handled: true, data: { error: err.message } };
2490
+ }
2491
+ }
2492
+ if (sub === 'ping') {
2493
+ const name = String(rest[1] || '').trim();
2494
+ if (!name) {
2495
+ console.log('Usage: /mcp ping <server>');
2496
+ return { handled: true };
2497
+ }
2498
+ try {
2499
+ const result = await runMcpRequest(opts, name, 'tools/list', {}, { timeoutMs: 15000 });
2500
+ console.log(`MCP server "${name}" responded. tools=${Array.isArray(result?.tools) ? result.tools.length : 0}`);
2501
+ return { handled: true, data: result };
2502
+ } catch (err) {
2503
+ console.log(`MCP ping failed: ${err.message}`);
2504
+ return { handled: true, data: { error: err.message } };
2505
+ }
2506
+ }
2507
+ if (sub === 'login') {
2508
+ const name = String(rest[1] || '');
2509
+ const row = getMergedMcpServers(opts).find((item) => item.name === name);
2510
+ if (!row) {
2511
+ console.log('MCP server not found.');
2512
+ return { handled: true };
2513
+ }
2514
+ console.log('OAuth login handoff is not interactive yet. Store headers or OAuth config in the MCP entry, then use /mcp get to verify it.');
2515
+ return { handled: true, data: row };
2516
+ }
2517
+ console.log('Usage: /mcp list|get|add|add-http|add-json|remove|tools|call|ping|login');
2518
+ return { handled: true };
2519
+ }
2520
+
2521
+ async function handleTranscriptCommand(opts, rest) {
2522
+ const sub = String(rest[0] || 'path').toLowerCase();
2523
+ if (sub === 'path') {
2524
+ console.log(getCurrentTranscriptPath(opts));
2525
+ return { handled: true, data: { path: getCurrentTranscriptPath(opts) } };
2526
+ }
2527
+ if (sub === 'list') {
2528
+ const rows = listTranscriptFiles(opts, rest.includes('--all')).slice(0, Number(rest[1]) || 20);
2529
+ if (!rows.length) console.log('No transcripts found.');
2530
+ rows.forEach((row, i) => console.log(`${i + 1}. ${row.id} ${Math.round(row.size / 1024)}KB ${row.filePath}`));
2531
+ return { handled: true, data: rows };
2532
+ }
2533
+ if (sub === 'export') {
2534
+ const current = getCurrentTranscriptPath(opts);
2535
+ if (!fs.existsSync(current)) {
2536
+ console.log('No current transcript exists yet.');
2537
+ return { handled: true };
2538
+ }
2539
+ const outPath = rest[1]
2540
+ ? resolveCliPath(opts, rest.slice(1).join(' '))
2541
+ : path.join(getTranscriptDir(opts), `${opts.transcriptId}.json`);
2542
+ const events = readTranscriptEvents(current, 100000);
2543
+ safeWriteJson(outPath, { version: 1, id: opts.transcriptId, cwd: opts.cwd, events });
2544
+ console.log(`Transcript exported: ${outPath}`);
2545
+ return { handled: true, data: { outPath, events: events.length } };
2546
+ }
2547
+ console.log('Usage: /transcript path|list [--all]|export [file]');
2548
+ return { handled: true };
2549
+ }
2550
+
2551
+ async function handleResumeCommand(opts, rest) {
2552
+ const sub = String(rest[0] || 'last').toLowerCase();
2553
+ if (sub === 'list') {
2554
+ const rows = listTranscriptFiles(opts, rest.includes('--all')).slice(0, 30);
2555
+ if (!rows.length) console.log('No transcripts found.');
2556
+ rows.forEach((row, i) => console.log(`${i + 1}. ${row.id} ${row.filePath}`));
2557
+ return { handled: true, data: rows };
2558
+ }
2559
+ const target = sub === 'last' ? 'last' : String(rest[0] || 'last');
2560
+ const found = findTranscriptByRef(opts, target, rest.includes('--all'));
2561
+ if (!found) {
2562
+ console.log('Transcript not found.');
2563
+ return { handled: true };
2564
+ }
2565
+ opts.resumeTranscript = found;
2566
+ opts.resumeContext = summarizeTranscriptForResume(found.filePath);
2567
+ opts._resumeContextUsed = false;
2568
+ console.log(`Resume context loaded: ${found.id}`);
2569
+ return { handled: true, data: found };
1090
2570
  }
1091
2571
 
1092
- function saveCliWorkspaceState(opts) {
1093
- const p = getCliSessionPath(opts);
1094
- const payload = {
1095
- version: 1,
1096
- cwd: opts.cwd,
1097
- updatedAt: Date.now(),
1098
- todo: sanitizeTodoItems(opts.todo),
1099
- plan: sanitizePlan(opts.plan),
1100
- history: sanitizeHistory(opts.commandHistory),
1101
- artifacts: sanitizeArtifacts(opts.artifacts),
1102
- runState: sanitizeRunState(opts.runState),
1103
- agentRunDefaults: sanitizeAgentRunDefaults(opts.agentRunDefaults)
1104
- };
1105
- fs.writeFileSync(p, JSON.stringify(payload, null, 2), 'utf8');
2572
+ async function handleHooksCommand(opts, rest) {
2573
+ const sub = String(rest[0] || 'list').toLowerCase();
2574
+ if (sub === 'list') {
2575
+ const rows = annotateHookTrust(loadHookEntries(opts));
2576
+ if (!rows.length) {
2577
+ console.log('No hooks configured.');
2578
+ console.log(`Project hooks: ${path.join(getBortexProjectDir(opts), 'hooks.json')}`);
2579
+ console.log(`User hooks : ${path.join(getBortexUserDir(), 'hooks.json')}`);
2580
+ return { handled: true, data: [] };
2581
+ }
2582
+ rows.forEach((row) => {
2583
+ const state = row.disabled ? 'disabled' : (row.trusted ? 'trusted' : 'untrusted');
2584
+ console.log(`${row.hash} ${row.event.padEnd(18)} ${(row.matcher || '*').padEnd(16)} ${state.padEnd(10)} ${row.command || row.type || 'hook'} [${row.source}]`);
2585
+ });
2586
+ return { handled: true, data: rows };
2587
+ }
2588
+ if (sub === 'init') {
2589
+ const filePath = path.join(getBortexProjectDir(opts), 'hooks.json');
2590
+ if (fs.existsSync(filePath)) {
2591
+ console.log(`Hooks file already exists: ${filePath}`);
2592
+ return { handled: true };
2593
+ }
2594
+ safeWriteJson(filePath, {
2595
+ hooks: {
2596
+ UserPromptSubmit: [],
2597
+ PreToolUse: [
2598
+ {
2599
+ type: 'command',
2600
+ matcher: 'rm -rf',
2601
+ command: 'node -e "console.log(JSON.stringify({permissionDecision:\\"deny\\",permissionDecisionReason:\\"Blocked destructive command\\"}))"'
2602
+ }
2603
+ ],
2604
+ PostToolUse: []
2605
+ }
2606
+ });
2607
+ console.log(`Created hooks file: ${filePath}`);
2608
+ return { handled: true, data: { filePath } };
2609
+ }
2610
+ if (sub === 'review') {
2611
+ const rows = annotateHookTrust(loadHookEntries(opts));
2612
+ if (!rows.length) {
2613
+ console.log('No hooks configured.');
2614
+ return { handled: true, data: [] };
2615
+ }
2616
+ rows.forEach((row) => {
2617
+ console.log('');
2618
+ console.log(`${row.hash} ${row.event} [${row.source}] ${row.trusted ? 'trusted' : 'untrusted'}${row.disabled ? ' disabled' : ''}`);
2619
+ console.log(`Matcher: ${row.matcher || '*'}`);
2620
+ console.log(`Command: ${row.command || '(none)'}`);
2621
+ console.log(`Path : ${row.filePath}`);
2622
+ });
2623
+ return { handled: true, data: rows };
2624
+ }
2625
+ if (sub === 'trust') {
2626
+ const target = String(rest[1] || '').trim();
2627
+ if (!target) {
2628
+ console.log('Usage: /hooks trust <hash|all>');
2629
+ return { handled: true };
2630
+ }
2631
+ const rows = loadHookEntries(opts);
2632
+ const trust = loadHookTrust();
2633
+ rows.filter((row) => target === 'all' || row.hash === target).forEach((row) => {
2634
+ trust.trusted[row.hash] = { ts: Date.now(), event: row.event, command: row.command || '' };
2635
+ delete trust.disabled[row.hash];
2636
+ });
2637
+ saveHookTrust(trust);
2638
+ console.log(`Trusted hooks: ${target}`);
2639
+ return { handled: true, data: trust };
2640
+ }
2641
+ if (sub === 'disable' || sub === 'enable') {
2642
+ const target = String(rest[1] || '').trim();
2643
+ if (!target) {
2644
+ console.log(`Usage: /hooks ${sub} <hash>`);
2645
+ return { handled: true };
2646
+ }
2647
+ const trust = loadHookTrust();
2648
+ if (sub === 'disable') trust.disabled[target] = { ts: Date.now() };
2649
+ else delete trust.disabled[target];
2650
+ saveHookTrust(trust);
2651
+ console.log(`${sub === 'disable' ? 'Disabled' : 'Enabled'} hook: ${target}`);
2652
+ return { handled: true, data: trust };
2653
+ }
2654
+ if (sub === 'test') {
2655
+ const event = String(rest[1] || 'UserPromptSubmit');
2656
+ const result = await runBortexHooks(opts, event, { prompt: rest.slice(2).join(' ') || 'test', command: rest.slice(2).join(' ') || 'test' }, { decision: true });
2657
+ console.log(JSON.stringify(result, null, 2));
2658
+ return { handled: true, data: result };
2659
+ }
2660
+ console.log('Usage: /hooks list|review|trust <hash|all>|disable <hash>|enable <hash>|init|test <event> [payload]');
2661
+ return { handled: true };
1106
2662
  }
1107
2663
 
1108
- function loadCliWorkspaceState(opts) {
1109
- const p = getCliSessionPath(opts);
1110
- opts.todo = [];
1111
- opts.plan = null;
1112
- opts.commandHistory = [];
1113
- opts.artifacts = [];
1114
- opts.runState = null;
1115
- opts.agentRunDefaults = sanitizeAgentRunDefaults(null);
1116
- if (!fs.existsSync(p)) return { loaded: false, path: p };
1117
- try {
1118
- const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
1119
- opts.todo = sanitizeTodoItems(raw.todo);
1120
- opts.plan = sanitizePlan(raw.plan);
1121
- opts.commandHistory = sanitizeHistory(raw.history);
1122
- opts.artifacts = sanitizeArtifacts(raw.artifacts);
1123
- opts.runState = sanitizeRunState(raw.runState);
1124
- opts.agentRunDefaults = sanitizeAgentRunDefaults(raw.agentRunDefaults);
1125
- return { loaded: true, path: p };
1126
- } catch (_err) {
1127
- opts.agentRunDefaults = sanitizeAgentRunDefaults(null);
1128
- return { loaded: false, path: p, invalid: true };
2664
+ async function handleSubagentsCommand(opts, rest, line) {
2665
+ const sub = String(rest[0] || 'list').toLowerCase();
2666
+ const agents = getAllSubagents(opts);
2667
+ if (sub === 'list') {
2668
+ agents.forEach((agent) => console.log(`${agent.name.padEnd(16)} ${agent.description} [${agent.scope}]`));
2669
+ return { handled: true, data: agents };
2670
+ }
2671
+ if (sub === 'run') {
2672
+ const raw = line.slice(line.toLowerCase().indexOf('run') + 3).trim();
2673
+ const agentMatch = raw.match(/(?:^|\s)--agents\s+([a-z0-9_,.-]+)(?=\s|$)/i);
2674
+ const useWorktree = /(?:^|\s)--worktree(?=\s|$)/i.test(raw);
2675
+ const selectedNames = agentMatch
2676
+ ? agentMatch[1].split(',').map((x) => safeSlug(x)).filter(Boolean)
2677
+ : ['reviewer', 'tester', 'security'];
2678
+ const goal = raw
2679
+ .replace(/(?:^|\s)--agents\s+[a-z0-9_,.-]+(?=\s|$)/ig, ' ')
2680
+ .replace(/(?:^|\s)--worktree(?=\s|$)/ig, ' ')
2681
+ .trim();
2682
+ if (!goal) {
2683
+ console.log('Usage: /subagents run [--agents reviewer,tester] <goal>');
2684
+ return { handled: true };
2685
+ }
2686
+ const selected = selectedNames.map((name) => agents.find((agent) => agent.name === name)).filter(Boolean);
2687
+ if (!selected.length) {
2688
+ console.log('No matching subagents.');
2689
+ return { handled: true };
2690
+ }
2691
+ if (opts.offline) {
2692
+ selected.forEach((agent) => console.log(`[${agent.name}] ${agent.instructions || agent.description}\nTask: ${goal}\n`));
2693
+ return { handled: true };
2694
+ }
2695
+ console.log(`Running ${selected.length} subagent(s): ${selected.map((a) => a.name).join(', ')}${useWorktree ? ' with isolated worktrees' : ''}`);
2696
+ const results = await Promise.all(selected.map(async (agent) => {
2697
+ let agentCwd = opts.cwd;
2698
+ let worktree = null;
2699
+ if (useWorktree) {
2700
+ const wtName = safeSlug(`${agent.name}-${Date.now().toString(36)}-${crypto.randomBytes(2).toString('hex')}`);
2701
+ const wtDir = path.join(getBortexProjectDir(opts), 'worktrees', wtName);
2702
+ const branch = `bortex/${wtName}`;
2703
+ const wtRes = await runChild('git', ['worktree', 'add', '-b', branch, wtDir, 'HEAD'], { cwd: opts.cwd, shell: false });
2704
+ if (wtRes.ok) {
2705
+ agentCwd = wtDir;
2706
+ worktree = { path: wtDir, branch };
2707
+ } else {
2708
+ return { agent: agent.name, ok: false, text: `Worktree creation failed: ${wtRes.stderr || wtRes.stdout}`, worktree: null };
2709
+ }
2710
+ }
2711
+ const prompt = [
2712
+ `You are Bortex subagent "${agent.name}".`,
2713
+ `Role: ${agent.description}`,
2714
+ worktree ? `Isolated worktree: ${worktree.path}` : '',
2715
+ agent.instructions ? `Instructions:\n${agent.instructions}` : '',
2716
+ '',
2717
+ `Task: ${goal}`,
2718
+ '',
2719
+ 'Return concise findings, risks, and next actions. Do not claim file edits unless you performed them.'
2720
+ ].filter(Boolean).join('\n');
2721
+ try {
2722
+ const data = await askServer({ ...opts, cwd: agentCwd, agent: false }, prompt);
2723
+ return { agent: agent.name, ok: true, text: data?.message || JSON.stringify(data || {}), worktree };
2724
+ } catch (err) {
2725
+ return { agent: agent.name, ok: false, text: err.message, worktree };
2726
+ }
2727
+ }));
2728
+ results.forEach((res) => {
2729
+ console.log('');
2730
+ console.log(`== ${res.agent} ${res.ok ? 'ok' : 'error'} ==`);
2731
+ console.log(res.text);
2732
+ });
2733
+ appendTranscriptEvent(opts, 'subagents', { goal, results });
2734
+ return { handled: true, data: results };
1129
2735
  }
2736
+ console.log('Usage: /subagents list|run [--agents a,b] <goal>');
2737
+ return { handled: true };
1130
2738
  }
1131
2739
 
1132
- function pushCliHistory(opts, line) {
1133
- if (!Array.isArray(opts.commandHistory)) opts.commandHistory = [];
1134
- const text = String(line || '').trim();
1135
- if (!text) return;
1136
- const last = opts.commandHistory[opts.commandHistory.length - 1];
1137
- if (last && last.text === text) {
1138
- last.ts = Date.now();
1139
- return;
2740
+ async function handleWorktreeCommand(opts, rest, line) {
2741
+ const sub = String(rest[0] || 'list').toLowerCase();
2742
+ if (sub === 'list') {
2743
+ const res = await runChild('git', ['worktree', 'list'], { cwd: opts.cwd, shell: false });
2744
+ printExecResult(res, '');
2745
+ return { handled: true, data: res };
1140
2746
  }
1141
- opts.commandHistory.push({ text, ts: Date.now() });
1142
- if (opts.commandHistory.length > 200) {
1143
- opts.commandHistory = opts.commandHistory.slice(-200);
2747
+ const name = safeSlug(sub === 'create' || sub === 'add' ? rest[1] : rest[0]);
2748
+ if (!name) {
2749
+ console.log('Usage: /fork <name> [base] or /worktrees list');
2750
+ return { handled: true };
1144
2751
  }
2752
+ const base = String(sub === 'create' || sub === 'add' ? (rest[2] || 'HEAD') : (rest[1] || 'HEAD'));
2753
+ const targetDir = path.join(getBortexProjectDir(opts), 'worktrees', name);
2754
+ ensureDirSync(path.dirname(targetDir));
2755
+ const branch = `bortex/${name}`;
2756
+ const res = await runChild('git', ['worktree', 'add', '-b', branch, targetDir, base], { cwd: opts.cwd, shell: false });
2757
+ printExecResult(res, `Worktree created: ${targetDir}`);
2758
+ return { handled: true, data: { targetDir, branch, ok: res.ok } };
1145
2759
  }
1146
2760
 
1147
- function recordArtifact(opts, kind, text) {
1148
- if (!Array.isArray(opts.artifacts)) opts.artifacts = [];
1149
- opts.artifacts.push({
1150
- ts: Date.now(),
1151
- kind: String(kind || 'event'),
1152
- text: String(text || '')
1153
- });
1154
- if (opts.artifacts.length > 300) {
1155
- opts.artifacts = opts.artifacts.slice(-300);
2761
+ async function handleGithubActionCommand(opts, rest) {
2762
+ const sub = String(rest[0] || 'init').toLowerCase();
2763
+ if (sub !== 'init') {
2764
+ console.log('Usage: /github-action init');
2765
+ return { handled: true };
1156
2766
  }
2767
+ const workflowPath = path.join(opts.cwd, '.github', 'workflows', 'bortex-code.yml');
2768
+ if (fs.existsSync(workflowPath)) {
2769
+ console.log(`Workflow already exists: ${workflowPath}`);
2770
+ return { handled: true };
2771
+ }
2772
+ ensureDirSync(path.dirname(workflowPath));
2773
+ const body = [
2774
+ 'name: Bortex Code',
2775
+ '',
2776
+ 'on:',
2777
+ ' issue_comment:',
2778
+ ' types: [created]',
2779
+ ' pull_request_review_comment:',
2780
+ ' types: [created]',
2781
+ '',
2782
+ 'jobs:',
2783
+ ' bortex:',
2784
+ ' if: contains(github.event.comment.body, \'@bortex\')',
2785
+ ' runs-on: ubuntu-latest',
2786
+ ' permissions:',
2787
+ ' contents: write',
2788
+ ' pull-requests: write',
2789
+ ' issues: write',
2790
+ ' steps:',
2791
+ ' - uses: actions/checkout@v4',
2792
+ ' - uses: actions/setup-node@v4',
2793
+ ' with:',
2794
+ ' node-version: "20"',
2795
+ ' - run: npm install -g bortexcode',
2796
+ ' - name: Run Bortex Code',
2797
+ ' env:',
2798
+ ' BORTEX_API_KEY: ${{ secrets.BORTEX_API_KEY }}',
2799
+ ' GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}',
2800
+ ' run: bortexcode --agent "${{ github.event.comment.body }}"'
2801
+ ].join('\n');
2802
+ fs.writeFileSync(workflowPath, body, 'utf8');
2803
+ console.log(`Created workflow: ${workflowPath}`);
2804
+ return { handled: true, data: { workflowPath } };
2805
+ }
2806
+
2807
+ async function runReviewPreset(opts, rest) {
2808
+ const baseIndex = rest.findIndex((x) => x === '--base' || x === 'base');
2809
+ const commitIndex = rest.findIndex((x) => x === '--commit' || x === 'commit');
2810
+ const customIndex = rest.findIndex((x) => x === '--custom' || x === 'custom');
2811
+ const ai = rest.includes('--ai');
2812
+ if (baseIndex >= 0) {
2813
+ const base = String(rest[baseIndex + 1] || 'main');
2814
+ const merge = await runChild('git', ['merge-base', base, 'HEAD'], { cwd: opts.cwd, shell: false });
2815
+ const baseRef = String(merge.stdout || '').trim() || base;
2816
+ const stat = await runChild('git', ['diff', '--stat', '--no-color', `${baseRef}..HEAD`], { cwd: opts.cwd, shell: false });
2817
+ const patch = await runChild('git', ['diff', '--no-color', `${baseRef}..HEAD`], { cwd: opts.cwd, shell: false });
2818
+ console.log(`Review preset: base branch ${base}`);
2819
+ printExecResult(stat, '(no diff)');
2820
+ if (ai && !opts.offline) {
2821
+ const data = await askServer(opts, `Review this diff against ${base}. Prioritize bugs, regressions, security, and missing tests.\n\n${String(patch.stdout || '').slice(0, 50000)}`);
2822
+ printResponse(opts, data);
2823
+ return { preset: 'base', base, ai: true, data };
2824
+ }
2825
+ return { preset: 'base', base, stat: stat.stdout };
2826
+ }
2827
+ if (commitIndex >= 0) {
2828
+ const sha = String(rest[commitIndex + 1] || 'HEAD');
2829
+ const show = await runChild('git', ['show', '--stat', '--patch', '--no-color', sha], { cwd: opts.cwd, shell: false });
2830
+ console.log(`Review preset: commit ${sha}`);
2831
+ process.stdout.write(String(show.stdout || show.stderr || '').slice(0, 50000));
2832
+ if (ai && !opts.offline) {
2833
+ const data = await askServer(opts, `Review this commit. Prioritize actionable findings with file/line references when possible.\n\n${String(show.stdout || '').slice(0, 50000)}`);
2834
+ printResponse(opts, data);
2835
+ return { preset: 'commit', sha, ai: true, data };
2836
+ }
2837
+ return { preset: 'commit', sha };
2838
+ }
2839
+ if (customIndex >= 0) {
2840
+ const instructions = rest.slice(customIndex + 1).filter((x) => x !== '--ai').join(' ').trim() || 'Focus on correctness and missing tests.';
2841
+ const diff = await runChild('git', ['diff', '--no-color'], { cwd: opts.cwd, shell: false });
2842
+ if (opts.offline) {
2843
+ console.log(`Custom review instructions: ${instructions}`);
2844
+ return { preset: 'custom', instructions };
2845
+ }
2846
+ const data = await askServer(opts, `Review the current uncommitted diff with these instructions: ${instructions}\n\n${String(diff.stdout || '').slice(0, 50000)}`);
2847
+ printResponse(opts, data);
2848
+ return { preset: 'custom', instructions, data };
2849
+ }
2850
+ return null;
1157
2851
  }
1158
2852
 
1159
2853
  function createRunStateFromGoal(goal) {
@@ -3967,7 +5661,7 @@ function terminateChildProcessTree(child) {
3967
5661
  }, 1500).unref?.();
3968
5662
  }
3969
5663
 
3970
- function runChild(command, args, { cwd, shell = false } = {}) {
5664
+ function runChild(command, args, { cwd, shell = false, env = null } = {}) {
3971
5665
  return new Promise((resolve) => {
3972
5666
  const cancelState = ACTIVE_REMOTE_CONTROL_EXECUTION_STATE;
3973
5667
  if (cancelState?.cancelRequested || cancelState?.revoked || cancelState?.active === false) {
@@ -3976,6 +5670,7 @@ function runChild(command, args, { cwd, shell = false } = {}) {
3976
5670
  const child = spawn(command, args, {
3977
5671
  cwd: cwd || process.cwd(),
3978
5672
  shell,
5673
+ env: env ? { ...process.env, ...env } : process.env,
3979
5674
  stdio: ['pipe', 'pipe', 'pipe'],
3980
5675
  windowsHide: true
3981
5676
  });
@@ -4041,6 +5736,18 @@ function printLocalHelp() {
4041
5736
  console.log(' /remote-control [name] [--lan|--host <host>|--port <port>]');
4042
5737
  console.log(' /remote-control --cloud [name]');
4043
5738
  console.log(' /remote-control stop');
5739
+ console.log(' /mcp list|get|add|add-http|add-json|remove|tools|call|ping|login');
5740
+ console.log(' /skills list|show <name>|new <name>');
5741
+ console.log(' /plugins list|show|init|install|enable|disable');
5742
+ console.log(' /permissions show|init|profile|check');
5743
+ console.log(' /browser status|setup');
5744
+ console.log(' /hooks list|review|trust|disable|enable|init|test <event>');
5745
+ console.log(' /subagents list|run [--agents a,b] <goal>');
5746
+ console.log(' /transcript path|list|export [file]');
5747
+ console.log(' /resume list|last|<id>');
5748
+ console.log(' /fork <name> [base] create isolated git worktree');
5749
+ console.log(' /worktrees list list git worktrees');
5750
+ console.log(' /github-action init create @bortex GitHub workflow');
4044
5751
  console.log(' /diff [unstaged|staged|all]');
4045
5752
  console.log(' /stage <file>|--all');
4046
5753
  console.log(' /unstage <file>|--all');
@@ -4705,7 +6412,8 @@ function isReadOnlyRemoteSlashCommand(line) {
4705
6412
  if ([
4706
6413
  '/help', '/commands', '/menu', '/status', '/pwd', '/ls', '/tree', '/read',
4707
6414
  '/diff', '/hunks', '/show-hunk', '/ssh-status', '/sys-status',
4708
- '/process-status', '/port-status', '/history'
6415
+ '/process-status', '/port-status', '/history', '/mcp', '/skills',
6416
+ '/plugins', '/permissions', '/browser', '/hooks', '/subagents', '/agents', '/transcript', '/resume', '/worktrees'
4709
6417
  ].includes(lc)) return true;
4710
6418
  if (lc === '/llm-config') {
4711
6419
  const sub = String(rest[0] || 'show').toLowerCase();
@@ -4752,6 +6460,7 @@ function assessRemoteControlPromptPermission(opts, line) {
4752
6460
  const mode = getRemoteControlPermissionMode(opts);
4753
6461
  const text = String(line || '').trim();
4754
6462
  if (!text) return { ok: false, mode, reason: 'empty prompt' };
6463
+ if (opts._remoteControlApprovalBypass) return { ok: true, mode, approved: true };
4755
6464
  if (mode === 'full') return { ok: true, mode };
4756
6465
  if (!text.startsWith('/')) return { ok: true, mode, skipNaturalActions: true };
4757
6466
  if (mode === 'read-only') {
@@ -4855,6 +6564,24 @@ async function postCloudRemoteChunk(state, commandId, stream, text) {
4855
6564
  }, 12000);
4856
6565
  }
4857
6566
 
6567
+ async function postCloudRemoteApprovalRequest(state, commandId, permission, text) {
6568
+ const approvalUrl = withRemoteControlQuery(
6569
+ state.approvalUrl || `${state.baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(state.sessionId)}/approval-request`,
6570
+ state.baseUrl,
6571
+ { agentToken: state.agentToken }
6572
+ );
6573
+ await fetchRemoteControlJson(approvalUrl, {
6574
+ method: 'POST',
6575
+ headers: { 'Content-Type': 'application/json' },
6576
+ body: JSON.stringify({
6577
+ commandId,
6578
+ text: String(text || ''),
6579
+ mode: permission?.mode || state.permission || 'balanced',
6580
+ reason: permission?.reason || 'This command requires explicit approval.'
6581
+ })
6582
+ }, 12000);
6583
+ }
6584
+
4858
6585
  function createCloudRemoteChunkStreamer(state, commandId) {
4859
6586
  const queue = [];
4860
6587
  let timer = null;
@@ -4972,10 +6699,32 @@ async function pollCloudRemoteControlLoop(opts, state) {
4972
6699
  if (!text) continue;
4973
6700
  state.cancelRequested = false;
4974
6701
  state.cancelCommandId = null;
6702
+ const approved = !!command.approved;
6703
+ const permission = assessRemoteControlPromptPermission(opts, text);
6704
+ if (!permission.ok && !approved) {
6705
+ state.busy = true;
6706
+ opts._remoteControlExecutionState = state;
6707
+ try {
6708
+ await postCloudRemoteApprovalRequest(state, commandId, permission, text);
6709
+ console.log(`Cloud Remote Control approval requested for command #${commandId}: ${permission.reason}.`);
6710
+ } catch (err) {
6711
+ const message = err.message || String(err);
6712
+ await postCloudRemoteResult(state, commandId, {
6713
+ ok: false,
6714
+ output: `Approval request failed: ${message}`
6715
+ }).catch(() => {});
6716
+ } finally {
6717
+ state.busy = false;
6718
+ delete opts._remoteControlExecutionState;
6719
+ }
6720
+ continue;
6721
+ }
4975
6722
  state.busy = true;
4976
6723
  opts._remoteControlExecutionState = state;
4977
6724
  const streamer = createCloudRemoteChunkStreamer(state, commandId);
4978
6725
  let result;
6726
+ const previousApprovalBypass = opts._remoteControlApprovalBypass;
6727
+ if (approved) opts._remoteControlApprovalBypass = true;
4979
6728
  try {
4980
6729
  result = await runRemoteControlPrompt(opts, text, (stream, chunk) => streamer.push(stream, chunk));
4981
6730
  } catch (err) {
@@ -4984,6 +6733,8 @@ async function pollCloudRemoteControlLoop(opts, state) {
4984
6733
  await streamer.flush().catch(() => {});
4985
6734
  state.busy = false;
4986
6735
  delete opts._remoteControlExecutionState;
6736
+ if (previousApprovalBypass === undefined) delete opts._remoteControlApprovalBypass;
6737
+ else opts._remoteControlApprovalBypass = previousApprovalBypass;
4987
6738
  }
4988
6739
  if (state.cancelRequested && result.ok) {
4989
6740
  result = { ok: false, output: 'Canceled by remote request.' };
@@ -5032,6 +6783,11 @@ async function startCloudRemoteControlSession(opts, overrides = {}) {
5032
6783
  const name = getRemoteControlDisplayName(opts, overrides);
5033
6784
  const permission = getRemoteControlPermissionMode(opts, overrides);
5034
6785
  opts.remoteControlPermission = permission;
6786
+ const sharedSettings = loadSharedSettings();
6787
+ const registerCookie = getCookieHeaderFromSharedSettings();
6788
+ const registerApiKey = String(
6789
+ opts.apiKey || process.env.BORTEX_API_KEY || sharedSettings?.llmSettings?.apiKey || ''
6790
+ ).trim();
5035
6791
  const payload = {
5036
6792
  name,
5037
6793
  cwd: opts.cwd,
@@ -5042,9 +6798,12 @@ async function startCloudRemoteControlSession(opts, overrides = {}) {
5042
6798
  mode: opts.agent ? 'agent' : 'chat',
5043
6799
  permission
5044
6800
  };
6801
+ const registerHeaders = { 'Content-Type': 'application/json' };
6802
+ if (registerCookie) registerHeaders.Cookie = registerCookie;
6803
+ if (registerApiKey) registerHeaders['x-api-key'] = registerApiKey;
5045
6804
  const data = await fetchRemoteControlJson(`${baseUrl}/api/bortex-code/remote/register`, {
5046
6805
  method: 'POST',
5047
- headers: { 'Content-Type': 'application/json' },
6806
+ headers: registerHeaders,
5048
6807
  body: JSON.stringify(payload)
5049
6808
  }, 30000);
5050
6809
 
@@ -5068,6 +6827,7 @@ async function startCloudRemoteControlSession(opts, overrides = {}) {
5068
6827
  pollUrl: data.pollUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/poll`,
5069
6828
  resultUrl: data.resultUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/result`,
5070
6829
  chunkUrl: data.chunkUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/chunk`,
6830
+ approvalUrl: data.approvalUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/approval-request`,
5071
6831
  heartbeatUrl: data.heartbeatUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/heartbeat`,
5072
6832
  controlUrl: data.controlUrl || `${baseUrl}/api/bortex-code/remote/session/${encodeURIComponent(sessionId)}/control`,
5073
6833
  lastCommandId: 0,
@@ -5088,6 +6848,8 @@ async function startCloudRemoteControlSession(opts, overrides = {}) {
5088
6848
 
5089
6849
  console.log(`Cloud Remote Control active: ${state.name}`);
5090
6850
  console.log(`URL: ${state.url}`);
6851
+ if (data.accountUrl) console.log(`Account URL: ${data.accountUrl}`);
6852
+ if (data.sessionsUrl) console.log(`Sessions: ${data.sessionsUrl}`);
5091
6853
  console.log(`Permission: ${state.permission}`);
5092
6854
  console.log('No inbound port required. Keep this process running.');
5093
6855
  console.log('Security: keep this tokenized URL private. Anyone with the URL can control this Bortex Code session.');
@@ -6452,17 +8214,69 @@ async function handleLocalCommand(opts, line) {
6452
8214
  const [cmd, ...rest] = parseWords(line);
6453
8215
  if (!cmd) return { handled: false };
6454
8216
  const lc = cmd.toLowerCase();
8217
+ const permissionDecision = checkLocalPermission(opts, lc, line);
8218
+ if (!permissionDecision.allowed) {
8219
+ console.log(`Command denied by permissions: ${permissionDecision.reason}`);
8220
+ return { handled: true, data: permissionDecision };
8221
+ }
8222
+ if (!opts._skipLocalCommandHooks && lc !== '/hooks') {
8223
+ const hookDecision = await runBortexHooks(opts, 'PreToolUse', {
8224
+ tool_name: 'SlashCommand',
8225
+ tool_input: { command: line },
8226
+ command: line
8227
+ }, { decision: true });
8228
+ if (hookDecision.denied) {
8229
+ console.log(`Command denied by hook: ${hookDecision.reason}`);
8230
+ return { handled: true, data: hookDecision };
8231
+ }
8232
+ }
6455
8233
  if (lc === '/help') {
6456
8234
  printLocalHelp();
6457
8235
  return { handled: true };
6458
8236
  }
6459
8237
  if (lc === '/commands' || lc === '/menu') {
6460
8238
  console.log('Commands');
6461
- SLASH_COMMANDS.forEach(([command, description]) => {
8239
+ getAllSlashCommandRows(opts).forEach(([command, description]) => {
6462
8240
  console.log(` ${command.padEnd(28)} ${description}`);
6463
8241
  });
6464
8242
  return { handled: true };
6465
8243
  }
8244
+ if (lc === '/skills' || lc === '/skill') {
8245
+ return handleSkillsCommand(opts, rest);
8246
+ }
8247
+ if (lc === '/plugins' || lc === '/plugin') {
8248
+ return handlePluginsCommand(opts, rest);
8249
+ }
8250
+ if (lc === '/permissions' || lc === '/permission' || lc === '/sandbox') {
8251
+ return handlePermissionsCommand(opts, rest);
8252
+ }
8253
+ if (lc === '/browser' || lc === '/playwright') {
8254
+ return handleBrowserCommand(opts, rest);
8255
+ }
8256
+ if (lc === '/mcp') {
8257
+ return handleMcpCommand(opts, rest, line);
8258
+ }
8259
+ if (lc === '/hooks') {
8260
+ return handleHooksCommand(opts, rest);
8261
+ }
8262
+ if (lc === '/transcript' || lc === '/transcripts') {
8263
+ return handleTranscriptCommand(opts, rest);
8264
+ }
8265
+ if (lc === '/resume') {
8266
+ return handleResumeCommand(opts, rest);
8267
+ }
8268
+ if (lc === '/subagents' || lc === '/agents') {
8269
+ return handleSubagentsCommand(opts, rest, line);
8270
+ }
8271
+ if (lc === '/fork' || lc === '/side' || lc === '/worktree' || lc === '/worktrees') {
8272
+ return handleWorktreeCommand(opts, rest, line);
8273
+ }
8274
+ if (lc === '/github-action' || lc === '/install-github-app') {
8275
+ return handleGithubActionCommand(opts, rest);
8276
+ }
8277
+ if (await handleCustomSlashCommand(opts, lc, rest, line)) {
8278
+ return { handled: true };
8279
+ }
6466
8280
  if (lc === '/agent') {
6467
8281
  const v = String(rest[0] || '').toLowerCase();
6468
8282
  if (!['on', 'off', 'true', 'false', '1', '0'].includes(v)) {
@@ -7475,6 +9289,11 @@ async function handleLocalCommand(opts, line) {
7475
9289
  return { handled: true };
7476
9290
  }
7477
9291
  if (lc === '/review') {
9292
+ const preset = await runReviewPreset(opts, rest);
9293
+ if (preset) {
9294
+ recordArtifact(opts, 'review-preset', JSON.stringify({ preset: preset.preset, base: preset.base, sha: preset.sha }).slice(0, 300));
9295
+ return { handled: true, data: preset };
9296
+ }
7478
9297
  let mode = 'all';
7479
9298
  if (rest.includes('--staged')) mode = 'staged';
7480
9299
  else if (rest.includes('--unstaged')) mode = 'unstaged';
@@ -7650,6 +9469,219 @@ function printResponse(opts, data) {
7650
9469
  }
7651
9470
  }
7652
9471
 
9472
+ function responseToFinalText(opts, data) {
9473
+ if (!opts?.agent) return String(data?.message || '');
9474
+ if (data?.message) return String(data.message);
9475
+ const applied = Array.isArray(data?.applied) ? data.applied : [];
9476
+ const errors = Array.isArray(data?.errors) ? data.errors : [];
9477
+ const lines = [];
9478
+ if (applied.length) lines.push(`Applied changes (${applied.length}):`, ...applied.map((p) => `- ${p}`));
9479
+ if (errors.length) lines.push(`Errors (${errors.length}):`, ...errors.map((e) => `- ${e.path || '?'}: ${e.error || 'error'}`));
9480
+ return lines.join('\n') || JSON.stringify(data || {});
9481
+ }
9482
+
9483
+ function emitExecJson(type, payload = {}) {
9484
+ process.stdout.write(`${JSON.stringify({ type, ...payload })}\n`);
9485
+ }
9486
+
9487
+ async function readPipedStdin() {
9488
+ if (process.stdin.isTTY) return '';
9489
+ let stat;
9490
+ try {
9491
+ stat = fs.fstatSync(0);
9492
+ if (!stat.isFIFO() && !stat.isFile()) return '';
9493
+ } catch (_err) {
9494
+ return '';
9495
+ }
9496
+ if (stat.isFile()) {
9497
+ try { return fs.readFileSync(0, 'utf8'); } catch (_err) { return ''; }
9498
+ }
9499
+ if (!stat.isFIFO()) return '';
9500
+ return new Promise((resolve) => {
9501
+ const chunks = [];
9502
+ let settled = false;
9503
+ const cleanup = () => {
9504
+ process.stdin.off('data', onFirstData);
9505
+ process.stdin.off('data', onData);
9506
+ process.stdin.off('end', onEnd);
9507
+ process.stdin.off('error', onError);
9508
+ try { process.stdin.pause(); } catch (_err) {}
9509
+ };
9510
+ const finish = () => {
9511
+ if (settled) return;
9512
+ settled = true;
9513
+ clearTimeout(timer);
9514
+ cleanup();
9515
+ resolve(Buffer.concat(chunks).toString('utf8'));
9516
+ };
9517
+ const onData = (chunk) => chunks.push(Buffer.from(chunk));
9518
+ const onFirstData = (chunk) => {
9519
+ clearTimeout(timer);
9520
+ chunks.push(Buffer.from(chunk));
9521
+ process.stdin.on('data', onData);
9522
+ };
9523
+ const onEnd = () => finish();
9524
+ const onError = () => {
9525
+ if (settled) return;
9526
+ settled = true;
9527
+ clearTimeout(timer);
9528
+ cleanup();
9529
+ resolve('');
9530
+ };
9531
+ const timer = setTimeout(() => {
9532
+ if (settled) return;
9533
+ settled = true;
9534
+ cleanup();
9535
+ resolve('');
9536
+ }, 200);
9537
+ if (typeof timer.unref === 'function') timer.unref();
9538
+ process.stdin.once('data', onFirstData);
9539
+ process.stdin.once('end', onEnd);
9540
+ process.stdin.once('error', onError);
9541
+ try { process.stdin.resume(); } catch (_err) { onError(); }
9542
+ });
9543
+ }
9544
+
9545
+ async function captureStdout(fn) {
9546
+ const originalWrite = process.stdout.write.bind(process.stdout);
9547
+ let output = '';
9548
+ process.stdout.write = (chunk, encoding, cb) => {
9549
+ output += Buffer.isBuffer(chunk) ? chunk.toString(typeof encoding === 'string' ? encoding : 'utf8') : String(chunk || '');
9550
+ if (typeof encoding === 'function') encoding();
9551
+ if (typeof cb === 'function') cb();
9552
+ return true;
9553
+ };
9554
+ try {
9555
+ const result = await fn();
9556
+ return { result, output };
9557
+ } finally {
9558
+ process.stdout.write = originalWrite;
9559
+ }
9560
+ }
9561
+
9562
+ function loadExecSchemaInstruction(opts) {
9563
+ if (!opts.execSchema) return '';
9564
+ const schemaPath = resolveCliPath(opts, opts.execSchema);
9565
+ const schemaText = fs.readFileSync(schemaPath, 'utf8');
9566
+ JSON.parse(schemaText);
9567
+ return [
9568
+ '',
9569
+ 'Structured output requirement:',
9570
+ 'Return only valid JSON. Do not wrap it in Markdown. The JSON must conform to this JSON Schema:',
9571
+ schemaText.trim()
9572
+ ].join('\n');
9573
+ }
9574
+
9575
+ async function runExecMode(opts) {
9576
+ const startedAt = Date.now();
9577
+ const threadId = opts.execEphemeral
9578
+ ? `exec-${Date.now().toString(36)}-${crypto.randomBytes(4).toString('hex')}`
9579
+ : ensureTranscriptId(opts);
9580
+ const json = !!opts.execJson;
9581
+ const oldSpinner = opts.ux.spinner;
9582
+ const oldProgress = opts.ux.progress;
9583
+ opts.ux.spinner = false;
9584
+ opts.ux.progress = false;
9585
+
9586
+ const stdinText = await readPipedStdin();
9587
+ let prompt = String(opts.prompt || '').trim();
9588
+ if (prompt === '-') {
9589
+ prompt = stdinText.trim();
9590
+ } else if (stdinText.trim()) {
9591
+ prompt = [
9592
+ prompt || 'Process the following stdin context.',
9593
+ '',
9594
+ 'STDIN context:',
9595
+ stdinText.trim()
9596
+ ].join('\n');
9597
+ }
9598
+
9599
+ if (!prompt) {
9600
+ const msg = 'Usage: bortexcode exec [--json] [-o file] [--output-schema file] <prompt>';
9601
+ if (json) emitExecJson('error', { message: msg });
9602
+ else console.error(msg);
9603
+ process.exitCode = 2;
9604
+ opts.ux.spinner = oldSpinner;
9605
+ opts.ux.progress = oldProgress;
9606
+ return;
9607
+ }
9608
+
9609
+ let schemaInstruction = '';
9610
+ try {
9611
+ schemaInstruction = loadExecSchemaInstruction(opts);
9612
+ } catch (err) {
9613
+ const msg = `Invalid --output-schema: ${err.message}`;
9614
+ if (json) emitExecJson('error', { message: msg });
9615
+ else console.error(msg);
9616
+ process.exitCode = 2;
9617
+ opts.ux.spinner = oldSpinner;
9618
+ opts.ux.progress = oldProgress;
9619
+ return;
9620
+ }
9621
+ if (schemaInstruction) prompt += schemaInstruction;
9622
+
9623
+ if (json) {
9624
+ emitExecJson('thread.started', { thread_id: threadId, cwd: opts.cwd, mode: opts.agent ? 'agent' : 'chat' });
9625
+ emitExecJson('turn.started', { thread_id: threadId });
9626
+ }
9627
+
9628
+ appendTranscriptEvent(opts, 'user', { text: prompt, exec: true, json, outputSchema: !!opts.execSchema });
9629
+ const submitHook = await runBortexHooks(opts, 'UserPromptSubmit', { prompt, exec: true }, { decision: true });
9630
+ if (submitHook.denied) {
9631
+ const msg = `Prompt denied by hook: ${submitHook.reason}`;
9632
+ if (json) emitExecJson('turn.failed', { thread_id: threadId, error: msg });
9633
+ else console.error(msg);
9634
+ process.exitCode = 1;
9635
+ opts.ux.spinner = oldSpinner;
9636
+ opts.ux.progress = oldProgress;
9637
+ return;
9638
+ }
9639
+
9640
+ try {
9641
+ let finalText = '';
9642
+ if (prompt.trim().startsWith('/')) {
9643
+ if (json) throw new Error('Exec --json does not support local slash commands yet. Run the slash command without --json.');
9644
+ const captured = await captureStdout(() => handleLocalCommand(opts, prompt.trim()));
9645
+ const localResult = captured.result;
9646
+ finalText = localResult.handled
9647
+ ? (captured.output.trim() || JSON.stringify(localResult.data ?? { handled: true }, null, 2))
9648
+ : '';
9649
+ } else {
9650
+ if (opts.offline) throw new Error('Exec mode needs the Bortex server unless the prompt is a local slash command.');
9651
+ const data = await askServer(opts, maybeAugmentPromptWithResumeContext(opts, prompt));
9652
+ finalText = responseToFinalText(opts, data);
9653
+ }
9654
+
9655
+ if (opts.execSchema) {
9656
+ try { JSON.parse(finalText); } catch (err) {
9657
+ throw new Error(`Model output did not parse as JSON for --output-schema: ${err.message}`);
9658
+ }
9659
+ }
9660
+
9661
+ if (opts.execOutput) {
9662
+ const outPath = resolveCliPath(opts, opts.execOutput);
9663
+ ensureDirSync(path.dirname(outPath));
9664
+ fs.writeFileSync(outPath, finalText.endsWith('\n') ? finalText : `${finalText}\n`, 'utf8');
9665
+ }
9666
+ appendTranscriptEvent(opts, 'assistant', { text: finalText, exec: true });
9667
+
9668
+ if (json) {
9669
+ emitExecJson('item.completed', { item: { id: 'final', type: 'agent_message', text: finalText } });
9670
+ emitExecJson('turn.completed', { thread_id: threadId, duration_ms: Date.now() - startedAt });
9671
+ } else {
9672
+ process.stdout.write(finalText.endsWith('\n') ? finalText : `${finalText}\n`);
9673
+ }
9674
+ } catch (err) {
9675
+ appendTranscriptEvent(opts, 'assistant-error', { exec: true, error: err.message });
9676
+ if (json) emitExecJson('turn.failed', { thread_id: threadId, error: err.message, duration_ms: Date.now() - startedAt });
9677
+ else console.error(err.message);
9678
+ process.exitCode = 1;
9679
+ } finally {
9680
+ opts.ux.spinner = oldSpinner;
9681
+ opts.ux.progress = oldProgress;
9682
+ }
9683
+ }
9684
+
7653
9685
  const SLASH_COMMANDS = [
7654
9686
  ['/agent on', 'Enable agent mode'],
7655
9687
  ['/agent off', 'Disable agent mode'],
@@ -7660,6 +9692,23 @@ const SLASH_COMMANDS = [
7660
9692
  ['/remote-control --lan', 'Expose Remote Control on the local network'],
7661
9693
  ['/remote-control --cloud', 'Control this session through bortex.site relay'],
7662
9694
  ['/rc', 'Toggle Remote Control'],
9695
+ ['/mcp list', 'List configured MCP servers'],
9696
+ ['/mcp add <name> -- <cmd>', 'Add a stdio MCP server'],
9697
+ ['/skills list', 'List custom skills and slash commands'],
9698
+ ['/skills new <name>', 'Create a project skill'],
9699
+ ['/plugins list', 'List installed plugins'],
9700
+ ['/plugins init <name>', 'Create a project plugin'],
9701
+ ['/permissions show', 'Show active permission profile'],
9702
+ ['/browser setup', 'Configure Playwright MCP browser automation'],
9703
+ ['/hooks list', 'List lifecycle hooks'],
9704
+ ['/hooks init', 'Create project hooks.json'],
9705
+ ['/subagents list', 'List available subagents'],
9706
+ ['/subagents run <goal>', 'Run a multi-agent analysis'],
9707
+ ['/transcript path', 'Show current transcript path'],
9708
+ ['/transcript export', 'Export current transcript as JSON'],
9709
+ ['/resume last', 'Load the latest transcript context'],
9710
+ ['/fork <name>', 'Create an isolated git worktree'],
9711
+ ['/github-action init', 'Create GitHub @bortex workflow'],
7663
9712
  ['/llm-config show', 'Show cached LLM configuration'],
7664
9713
  ['/llm-config sync', 'Sync LLM configuration from Bortex'],
7665
9714
  ['/pwd', 'Show current working directory'],
@@ -7683,10 +9732,10 @@ const SLASH_COMMANDS = [
7683
9732
  ['/exit', 'Exit']
7684
9733
  ];
7685
9734
 
7686
- function getSlashCompletions(line = '') {
9735
+ function getSlashCompletions(line = '', opts = {}) {
7687
9736
  const prefix = String(line || '').trim();
7688
9737
  if (!prefix.startsWith('/')) return [];
7689
- return SLASH_COMMANDS
9738
+ return getAllSlashCommandRows(opts)
7690
9739
  .map(([command]) => command)
7691
9740
  .filter((command) => command.startsWith(prefix));
7692
9741
  }
@@ -7696,7 +9745,8 @@ function printSlashMenu(rl, opts) {
7696
9745
  const prompt = opts._currentPrompt || `bortex:${opts.agent ? 'agent' : 'chat'}> `;
7697
9746
  const line = String(rl.line || '');
7698
9747
  const query = line.startsWith('/') ? line : '/';
7699
- const rows = SLASH_COMMANDS
9748
+ const allRows = getAllSlashCommandRows(opts);
9749
+ const rows = allRows
7700
9750
  .filter(([command]) => command.startsWith(query) || query === '/')
7701
9751
  .slice(0, 12);
7702
9752
 
@@ -7705,7 +9755,7 @@ function printSlashMenu(rl, opts) {
7705
9755
  rows.forEach(([command, description]) => {
7706
9756
  console.log(` ${command.padEnd(28)} ${description}`);
7707
9757
  });
7708
- if (SLASH_COMMANDS.length > rows.length) {
9758
+ if (allRows.length > rows.length) {
7709
9759
  console.log(` ${'...'.padEnd(28)} Keep typing to narrow results`);
7710
9760
  }
7711
9761
  process.stdout.write('\n');
@@ -7780,23 +9830,40 @@ function printWelcomeCard(opts) {
7780
9830
  }
7781
9831
 
7782
9832
  async function runSinglePrompt(opts) {
7783
- if (String(opts.prompt || '').trim().startsWith('/')) {
7784
- const localResult = await handleLocalCommand(opts, opts.prompt);
7785
- if (localResult.handled) return;
9833
+ const rawPrompt = String(opts.prompt || '').trim();
9834
+ appendTranscriptEvent(opts, 'user', { text: rawPrompt, singlePrompt: true });
9835
+ const submitHook = await runBortexHooks(opts, 'UserPromptSubmit', { prompt: rawPrompt }, { decision: true });
9836
+ if (submitHook.denied) {
9837
+ console.log(`Prompt denied by hook: ${submitHook.reason}`);
9838
+ return;
9839
+ }
9840
+ if (rawPrompt.startsWith('/')) {
9841
+ const localResult = await handleLocalCommand(opts, rawPrompt);
9842
+ if (localResult.handled) {
9843
+ appendTranscriptEvent(opts, 'tool', { command: rawPrompt, data: localResult.data || null });
9844
+ return;
9845
+ }
9846
+ }
9847
+ const naturalFileAction = await runNaturalLocalFileAction(opts, rawPrompt);
9848
+ if (naturalFileAction.handled) {
9849
+ appendTranscriptEvent(opts, 'tool', { command: 'natural-file-action', prompt: rawPrompt, data: naturalFileAction.data || null });
9850
+ return;
7786
9851
  }
7787
- const naturalFileAction = await runNaturalLocalFileAction(opts, opts.prompt);
7788
- if (naturalFileAction.handled) return;
7789
- const localIntent = classifyLocalPromptIntent(opts.prompt);
9852
+ const localIntent = classifyLocalPromptIntent(rawPrompt);
7790
9853
  if (localIntent?.command) {
7791
9854
  const localResult = await handleLocalCommand(opts, localIntent.command);
7792
- if (localResult.handled) return;
9855
+ if (localResult.handled) {
9856
+ appendTranscriptEvent(opts, 'tool', { command: localIntent.command, data: localResult.data || null });
9857
+ return;
9858
+ }
7793
9859
  }
7794
9860
  if (opts.offline) {
7795
9861
  console.error('Modalita --offline: usa REPL interattiva e comandi locali (/help).');
7796
9862
  process.exit(1);
7797
9863
  }
7798
- const data = await askServer(opts, opts.prompt);
9864
+ const data = await askServer(opts, maybeAugmentPromptWithResumeContext(opts, rawPrompt));
7799
9865
  printResponse(opts, data);
9866
+ appendTranscriptEvent(opts, 'assistant', { text: data?.message || JSON.stringify(data || {}) });
7800
9867
  }
7801
9868
 
7802
9869
  async function runRepl(opts) {
@@ -7818,8 +9885,8 @@ async function runRepl(opts) {
7818
9885
  terminal: true,
7819
9886
  completer: (line) => {
7820
9887
  if (!String(line || '').startsWith('/')) return [[], line];
7821
- const hits = getSlashCompletions(line);
7822
- return [hits.length ? hits : SLASH_COMMANDS.map(([command]) => command), line];
9888
+ const hits = getSlashCompletions(line, opts);
9889
+ return [hits.length ? hits : getAllSlashCommandRows(opts).map(([command]) => command), line];
7823
9890
  }
7824
9891
  });
7825
9892
  const uninstallSlashMenu = installSlashMenu(rl, opts);
@@ -7858,6 +9925,13 @@ async function runRepl(opts) {
7858
9925
  while (true) {
7859
9926
  const line = (await question()).trim();
7860
9927
  if (!line) continue;
9928
+ appendTranscriptEvent(opts, 'user', { text: line });
9929
+ const submitHook = await runBortexHooks(opts, 'UserPromptSubmit', { prompt: line }, { decision: true });
9930
+ if (submitHook.denied) {
9931
+ console.log(`Prompt denied by hook: ${submitHook.reason}`);
9932
+ appendTranscriptEvent(opts, 'hook-deny', { event: 'UserPromptSubmit', reason: submitHook.reason });
9933
+ continue;
9934
+ }
7861
9935
  pushCliHistory(opts, line);
7862
9936
  try { saveCliWorkspaceState(opts); } catch (_err) { }
7863
9937
  if (line === '/exit' || line === '/quit') break;
@@ -7883,26 +9957,38 @@ async function runRepl(opts) {
7883
9957
  }
7884
9958
  try {
7885
9959
  const localResult = await handleLocalCommand(opts, line);
7886
- if (localResult.handled) continue;
9960
+ if (localResult.handled) {
9961
+ appendTranscriptEvent(opts, 'tool', { command: line, data: localResult.data || null });
9962
+ continue;
9963
+ }
7887
9964
  } catch (err) {
7888
9965
  console.error(`Local tool error: ${err.message}`);
9966
+ appendTranscriptEvent(opts, 'tool-error', { command: line, error: err.message });
7889
9967
  continue;
7890
9968
  }
7891
9969
  const localIntent = classifyLocalPromptIntent(line);
7892
9970
  if (localIntent?.command) {
7893
9971
  try {
7894
9972
  const localResult = await handleLocalCommand(opts, localIntent.command);
7895
- if (localResult.handled) continue;
9973
+ if (localResult.handled) {
9974
+ appendTranscriptEvent(opts, 'tool', { command: localIntent.command, sourcePrompt: line, data: localResult.data || null });
9975
+ continue;
9976
+ }
7896
9977
  } catch (err) {
7897
9978
  console.error(`Local tool error: ${err.message}`);
9979
+ appendTranscriptEvent(opts, 'tool-error', { command: localIntent.command, sourcePrompt: line, error: err.message });
7898
9980
  continue;
7899
9981
  }
7900
9982
  }
7901
9983
  try {
7902
9984
  const naturalFileAction = await runNaturalLocalFileAction(opts, line);
7903
- if (naturalFileAction.handled) continue;
9985
+ if (naturalFileAction.handled) {
9986
+ appendTranscriptEvent(opts, 'tool', { command: 'natural-file-action', prompt: line, data: naturalFileAction.data || null });
9987
+ continue;
9988
+ }
7904
9989
  } catch (err) {
7905
9990
  console.error(`Local tool error: ${err.message}`);
9991
+ appendTranscriptEvent(opts, 'tool-error', { command: 'natural-file-action', prompt: line, error: err.message });
7906
9992
  continue;
7907
9993
  }
7908
9994
  if (opts.offline) {
@@ -7910,10 +9996,12 @@ async function runRepl(opts) {
7910
9996
  continue;
7911
9997
  }
7912
9998
  try {
7913
- const data = await askServer(opts, line);
9999
+ const data = await askServer(opts, maybeAugmentPromptWithResumeContext(opts, line));
7914
10000
  printResponse(opts, data);
10001
+ appendTranscriptEvent(opts, 'assistant', { text: data?.message || JSON.stringify(data || {}) });
7915
10002
  } catch (err) {
7916
10003
  console.error(err.message);
10004
+ appendTranscriptEvent(opts, 'assistant-error', { prompt: line, error: err.message });
7917
10005
  }
7918
10006
  }
7919
10007
 
@@ -7975,6 +10063,10 @@ async function main() {
7975
10063
  opts.sessionReused = false;
7976
10064
  }
7977
10065
 
10066
+ if (opts.resume) {
10067
+ await prepareResumeContext(opts);
10068
+ }
10069
+
7978
10070
  if (opts.remoteControlServerMode) {
7979
10071
  let remoteState = null;
7980
10072
  if (opts.remoteControlCloud) {
@@ -8020,6 +10112,11 @@ async function main() {
8020
10112
  return;
8021
10113
  }
8022
10114
 
10115
+ if (opts.execMode) {
10116
+ await runExecMode(opts);
10117
+ return;
10118
+ }
10119
+
8023
10120
  if (opts.prompt) {
8024
10121
  await runSinglePrompt(opts);
8025
10122
  return;
@@ -8049,6 +10146,24 @@ if (require.main === module) {
8049
10146
  isRiskyStructuredToolCall,
8050
10147
  diffCliToolReports,
8051
10148
  extractResumableCallsFromReportPayload,
8052
- detectGitRepoOperationState
10149
+ detectGitRepoOperationState,
10150
+ loadCustomSlashCommands,
10151
+ getAllSlashCommandRows,
10152
+ loadMcpConfig,
10153
+ getMergedMcpServers,
10154
+ resolveMcpServer,
10155
+ runMcpRequest,
10156
+ appendTranscriptEvent,
10157
+ listTranscriptFiles,
10158
+ findTranscriptByRef,
10159
+ summarizeTranscriptForResume,
10160
+ loadHookEntries,
10161
+ annotateHookTrust,
10162
+ discoverPlugins,
10163
+ getEffectivePermissionProfile,
10164
+ checkLocalPermission,
10165
+ responseToFinalText,
10166
+ getAllSubagents,
10167
+ redactSensitiveText
8053
10168
  };
8054
10169
  }