bortexcode 1.6.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 +2149 -88
  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 };
1090
2549
  }
1091
2550
 
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');
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 };
1106
2570
  }
1107
2571
 
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 };
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 };
1129
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 };
1130
2662
  }
1131
2663
 
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;
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 };
1140
2735
  }
1141
- opts.commandHistory.push({ text, ts: Date.now() });
1142
- if (opts.commandHistory.length > 200) {
1143
- opts.commandHistory = opts.commandHistory.slice(-200);
2736
+ console.log('Usage: /subagents list|run [--agents a,b] <goal>');
2737
+ return { handled: true };
2738
+ }
2739
+
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 };
2746
+ }
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 };
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 };
1156
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();
@@ -6506,17 +8214,69 @@ async function handleLocalCommand(opts, line) {
6506
8214
  const [cmd, ...rest] = parseWords(line);
6507
8215
  if (!cmd) return { handled: false };
6508
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
+ }
6509
8233
  if (lc === '/help') {
6510
8234
  printLocalHelp();
6511
8235
  return { handled: true };
6512
8236
  }
6513
8237
  if (lc === '/commands' || lc === '/menu') {
6514
8238
  console.log('Commands');
6515
- SLASH_COMMANDS.forEach(([command, description]) => {
8239
+ getAllSlashCommandRows(opts).forEach(([command, description]) => {
6516
8240
  console.log(` ${command.padEnd(28)} ${description}`);
6517
8241
  });
6518
8242
  return { handled: true };
6519
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
+ }
6520
8280
  if (lc === '/agent') {
6521
8281
  const v = String(rest[0] || '').toLowerCase();
6522
8282
  if (!['on', 'off', 'true', 'false', '1', '0'].includes(v)) {
@@ -7529,6 +9289,11 @@ async function handleLocalCommand(opts, line) {
7529
9289
  return { handled: true };
7530
9290
  }
7531
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
+ }
7532
9297
  let mode = 'all';
7533
9298
  if (rest.includes('--staged')) mode = 'staged';
7534
9299
  else if (rest.includes('--unstaged')) mode = 'unstaged';
@@ -7704,6 +9469,219 @@ function printResponse(opts, data) {
7704
9469
  }
7705
9470
  }
7706
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
+
7707
9685
  const SLASH_COMMANDS = [
7708
9686
  ['/agent on', 'Enable agent mode'],
7709
9687
  ['/agent off', 'Disable agent mode'],
@@ -7714,6 +9692,23 @@ const SLASH_COMMANDS = [
7714
9692
  ['/remote-control --lan', 'Expose Remote Control on the local network'],
7715
9693
  ['/remote-control --cloud', 'Control this session through bortex.site relay'],
7716
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'],
7717
9712
  ['/llm-config show', 'Show cached LLM configuration'],
7718
9713
  ['/llm-config sync', 'Sync LLM configuration from Bortex'],
7719
9714
  ['/pwd', 'Show current working directory'],
@@ -7737,10 +9732,10 @@ const SLASH_COMMANDS = [
7737
9732
  ['/exit', 'Exit']
7738
9733
  ];
7739
9734
 
7740
- function getSlashCompletions(line = '') {
9735
+ function getSlashCompletions(line = '', opts = {}) {
7741
9736
  const prefix = String(line || '').trim();
7742
9737
  if (!prefix.startsWith('/')) return [];
7743
- return SLASH_COMMANDS
9738
+ return getAllSlashCommandRows(opts)
7744
9739
  .map(([command]) => command)
7745
9740
  .filter((command) => command.startsWith(prefix));
7746
9741
  }
@@ -7750,7 +9745,8 @@ function printSlashMenu(rl, opts) {
7750
9745
  const prompt = opts._currentPrompt || `bortex:${opts.agent ? 'agent' : 'chat'}> `;
7751
9746
  const line = String(rl.line || '');
7752
9747
  const query = line.startsWith('/') ? line : '/';
7753
- const rows = SLASH_COMMANDS
9748
+ const allRows = getAllSlashCommandRows(opts);
9749
+ const rows = allRows
7754
9750
  .filter(([command]) => command.startsWith(query) || query === '/')
7755
9751
  .slice(0, 12);
7756
9752
 
@@ -7759,7 +9755,7 @@ function printSlashMenu(rl, opts) {
7759
9755
  rows.forEach(([command, description]) => {
7760
9756
  console.log(` ${command.padEnd(28)} ${description}`);
7761
9757
  });
7762
- if (SLASH_COMMANDS.length > rows.length) {
9758
+ if (allRows.length > rows.length) {
7763
9759
  console.log(` ${'...'.padEnd(28)} Keep typing to narrow results`);
7764
9760
  }
7765
9761
  process.stdout.write('\n');
@@ -7834,23 +9830,40 @@ function printWelcomeCard(opts) {
7834
9830
  }
7835
9831
 
7836
9832
  async function runSinglePrompt(opts) {
7837
- if (String(opts.prompt || '').trim().startsWith('/')) {
7838
- const localResult = await handleLocalCommand(opts, opts.prompt);
7839
- 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;
7840
9851
  }
7841
- const naturalFileAction = await runNaturalLocalFileAction(opts, opts.prompt);
7842
- if (naturalFileAction.handled) return;
7843
- const localIntent = classifyLocalPromptIntent(opts.prompt);
9852
+ const localIntent = classifyLocalPromptIntent(rawPrompt);
7844
9853
  if (localIntent?.command) {
7845
9854
  const localResult = await handleLocalCommand(opts, localIntent.command);
7846
- if (localResult.handled) return;
9855
+ if (localResult.handled) {
9856
+ appendTranscriptEvent(opts, 'tool', { command: localIntent.command, data: localResult.data || null });
9857
+ return;
9858
+ }
7847
9859
  }
7848
9860
  if (opts.offline) {
7849
9861
  console.error('Modalita --offline: usa REPL interattiva e comandi locali (/help).');
7850
9862
  process.exit(1);
7851
9863
  }
7852
- const data = await askServer(opts, opts.prompt);
9864
+ const data = await askServer(opts, maybeAugmentPromptWithResumeContext(opts, rawPrompt));
7853
9865
  printResponse(opts, data);
9866
+ appendTranscriptEvent(opts, 'assistant', { text: data?.message || JSON.stringify(data || {}) });
7854
9867
  }
7855
9868
 
7856
9869
  async function runRepl(opts) {
@@ -7872,8 +9885,8 @@ async function runRepl(opts) {
7872
9885
  terminal: true,
7873
9886
  completer: (line) => {
7874
9887
  if (!String(line || '').startsWith('/')) return [[], line];
7875
- const hits = getSlashCompletions(line);
7876
- 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];
7877
9890
  }
7878
9891
  });
7879
9892
  const uninstallSlashMenu = installSlashMenu(rl, opts);
@@ -7912,6 +9925,13 @@ async function runRepl(opts) {
7912
9925
  while (true) {
7913
9926
  const line = (await question()).trim();
7914
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
+ }
7915
9935
  pushCliHistory(opts, line);
7916
9936
  try { saveCliWorkspaceState(opts); } catch (_err) { }
7917
9937
  if (line === '/exit' || line === '/quit') break;
@@ -7937,26 +9957,38 @@ async function runRepl(opts) {
7937
9957
  }
7938
9958
  try {
7939
9959
  const localResult = await handleLocalCommand(opts, line);
7940
- if (localResult.handled) continue;
9960
+ if (localResult.handled) {
9961
+ appendTranscriptEvent(opts, 'tool', { command: line, data: localResult.data || null });
9962
+ continue;
9963
+ }
7941
9964
  } catch (err) {
7942
9965
  console.error(`Local tool error: ${err.message}`);
9966
+ appendTranscriptEvent(opts, 'tool-error', { command: line, error: err.message });
7943
9967
  continue;
7944
9968
  }
7945
9969
  const localIntent = classifyLocalPromptIntent(line);
7946
9970
  if (localIntent?.command) {
7947
9971
  try {
7948
9972
  const localResult = await handleLocalCommand(opts, localIntent.command);
7949
- if (localResult.handled) continue;
9973
+ if (localResult.handled) {
9974
+ appendTranscriptEvent(opts, 'tool', { command: localIntent.command, sourcePrompt: line, data: localResult.data || null });
9975
+ continue;
9976
+ }
7950
9977
  } catch (err) {
7951
9978
  console.error(`Local tool error: ${err.message}`);
9979
+ appendTranscriptEvent(opts, 'tool-error', { command: localIntent.command, sourcePrompt: line, error: err.message });
7952
9980
  continue;
7953
9981
  }
7954
9982
  }
7955
9983
  try {
7956
9984
  const naturalFileAction = await runNaturalLocalFileAction(opts, line);
7957
- 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
+ }
7958
9989
  } catch (err) {
7959
9990
  console.error(`Local tool error: ${err.message}`);
9991
+ appendTranscriptEvent(opts, 'tool-error', { command: 'natural-file-action', prompt: line, error: err.message });
7960
9992
  continue;
7961
9993
  }
7962
9994
  if (opts.offline) {
@@ -7964,10 +9996,12 @@ async function runRepl(opts) {
7964
9996
  continue;
7965
9997
  }
7966
9998
  try {
7967
- const data = await askServer(opts, line);
9999
+ const data = await askServer(opts, maybeAugmentPromptWithResumeContext(opts, line));
7968
10000
  printResponse(opts, data);
10001
+ appendTranscriptEvent(opts, 'assistant', { text: data?.message || JSON.stringify(data || {}) });
7969
10002
  } catch (err) {
7970
10003
  console.error(err.message);
10004
+ appendTranscriptEvent(opts, 'assistant-error', { prompt: line, error: err.message });
7971
10005
  }
7972
10006
  }
7973
10007
 
@@ -8029,6 +10063,10 @@ async function main() {
8029
10063
  opts.sessionReused = false;
8030
10064
  }
8031
10065
 
10066
+ if (opts.resume) {
10067
+ await prepareResumeContext(opts);
10068
+ }
10069
+
8032
10070
  if (opts.remoteControlServerMode) {
8033
10071
  let remoteState = null;
8034
10072
  if (opts.remoteControlCloud) {
@@ -8074,6 +10112,11 @@ async function main() {
8074
10112
  return;
8075
10113
  }
8076
10114
 
10115
+ if (opts.execMode) {
10116
+ await runExecMode(opts);
10117
+ return;
10118
+ }
10119
+
8077
10120
  if (opts.prompt) {
8078
10121
  await runSinglePrompt(opts);
8079
10122
  return;
@@ -8103,6 +10146,24 @@ if (require.main === module) {
8103
10146
  isRiskyStructuredToolCall,
8104
10147
  diffCliToolReports,
8105
10148
  extractResumableCallsFromReportPayload,
8106
- 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
8107
10168
  };
8108
10169
  }