nexrall-code 0.5.47 → 0.5.49

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 (2) hide show
  1. package/dist/index.js +832 -104
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -3156,6 +3156,8 @@ var require_auth = __commonJS({
3156
3156
  exports.clearAuth = clearAuth2;
3157
3157
  exports.isAuthenticated = isAuthenticated3;
3158
3158
  exports.getToken = getToken;
3159
+ exports.getRefreshToken = getRefreshToken;
3160
+ exports.updateTokens = updateTokens;
3159
3161
  var fs9 = __importStar(__require("fs"));
3160
3162
  var path6 = __importStar(__require("path"));
3161
3163
  var os6 = __importStar(__require("os"));
@@ -3205,6 +3207,34 @@ var require_auth = __commonJS({
3205
3207
  const auth = loadAuth();
3206
3208
  return auth?.token ?? null;
3207
3209
  }
3210
+ function getRefreshToken() {
3211
+ const envToken = process.env.NEXRALL_TOKEN;
3212
+ if (envToken && envToken.trim().length > 0)
3213
+ return null;
3214
+ const auth = loadAuth();
3215
+ const rt = auth?.refreshToken;
3216
+ return typeof rt === "string" && rt.length > 0 ? rt : null;
3217
+ }
3218
+ function updateTokens(token, refreshToken) {
3219
+ let existing = {};
3220
+ try {
3221
+ if (fs9.existsSync(CONFIG_FILE)) {
3222
+ const parsed = JSON.parse(fs9.readFileSync(CONFIG_FILE, "utf-8"));
3223
+ if (typeof parsed === "object" && parsed !== null)
3224
+ existing = parsed;
3225
+ }
3226
+ } catch {
3227
+ }
3228
+ ensureConfigDir();
3229
+ const next = {
3230
+ ...existing,
3231
+ token,
3232
+ // Keep the previous refresh token if the server didn't send a new one, rather
3233
+ // than deleting the only means of refreshing again.
3234
+ ...refreshToken ? { refreshToken } : {}
3235
+ };
3236
+ fs9.writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2), { mode: 384 });
3237
+ }
3208
3238
  }
3209
3239
  });
3210
3240
 
@@ -9842,6 +9872,7 @@ var require_client = __commonJS({
9842
9872
  exports.chooseFinalContent = chooseFinalContent;
9843
9873
  exports.streamChat = streamChat;
9844
9874
  exports.cancelTurn = cancelTurn;
9875
+ exports.revokeRefreshToken = revokeRefreshToken2;
9845
9876
  exports.getBalance = getBalance3;
9846
9877
  exports.exchangeVscodeCode = exchangeVscodeCode;
9847
9878
  exports.login = login2;
@@ -9892,6 +9923,49 @@ var require_client = __commonJS({
9892
9923
  Authorization: `Bearer ${token}`
9893
9924
  };
9894
9925
  }
9926
+ var _refreshInFlight = null;
9927
+ async function refreshAccessToken() {
9928
+ if (_refreshInFlight)
9929
+ return _refreshInFlight;
9930
+ _refreshInFlight = (async () => {
9931
+ const refreshToken = (0, index_1.getRefreshToken)();
9932
+ if (!refreshToken)
9933
+ return false;
9934
+ try {
9935
+ const res = await (0, node_fetch_1.default)(`${exports.API_BASE}/api/auth/refresh`, {
9936
+ method: "POST",
9937
+ headers: { "Content-Type": "application/json" },
9938
+ body: JSON.stringify({ refreshToken })
9939
+ });
9940
+ if (!res.ok)
9941
+ return false;
9942
+ const data = await res.json();
9943
+ if (!data.token)
9944
+ return false;
9945
+ (0, index_1.updateTokens)(data.token, data.refreshToken);
9946
+ return true;
9947
+ } catch {
9948
+ return false;
9949
+ }
9950
+ })();
9951
+ try {
9952
+ return await _refreshInFlight;
9953
+ } finally {
9954
+ _refreshInFlight = null;
9955
+ }
9956
+ }
9957
+ function isExpiredTokenResponse(status, body) {
9958
+ if (status !== 401 && status !== 403)
9959
+ return false;
9960
+ try {
9961
+ const parsed = JSON.parse(body);
9962
+ if (parsed?.error && typeof parsed.error === "object")
9963
+ return false;
9964
+ return typeof parsed?.error === "string" && /token|auth|expired/i.test(parsed.error);
9965
+ } catch {
9966
+ return false;
9967
+ }
9968
+ }
9895
9969
  var MAX_RETRIES = 5;
9896
9970
  var RETRY_BASE_MS = 1e3;
9897
9971
  var RETRY_MAX_MS = 3e4;
@@ -9974,14 +10048,43 @@ var require_client = __commonJS({
9974
10048
  onEvent({ type: "retry_resolved" });
9975
10049
  }
9976
10050
  };
10051
+ const prepareRestart = async (reason) => {
10052
+ await cancelTurn(turnId);
10053
+ turnId = (0, crypto_1.randomUUID)();
10054
+ resuming = false;
10055
+ serverResumable = false;
10056
+ lastEventId = 0;
10057
+ carryText = [];
10058
+ carryToolUse = [];
10059
+ const hadRendered = emittedAnythingAcrossAttempts;
10060
+ const renderedChars = emittedCharsAcrossAttempts;
10061
+ emittedAnythingAcrossAttempts = false;
10062
+ emittedCharsAcrossAttempts = 0;
10063
+ if (hadRendered && !allowRestartAfterRender)
10064
+ return false;
10065
+ if (hadRendered) {
10066
+ onEvent({ type: "stream_restart", reason, discardedChars: renderedChars });
10067
+ }
10068
+ return true;
10069
+ };
10070
+ const bodySaysNotResumable = (body) => {
10071
+ try {
10072
+ return JSON.parse(body)?.notResumable === true;
10073
+ } catch {
10074
+ return false;
10075
+ }
10076
+ };
9977
10077
  async function runAttempt() {
9978
10078
  let response;
9979
10079
  let lastErr;
9980
10080
  let inFlightWaits = 0;
9981
10081
  let errorBodyOverride = null;
9982
10082
  let forbiddenExhausted = false;
10083
+ let refreshedThisAttempt = false;
10084
+ let sessionExpired = false;
9983
10085
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
9984
10086
  try {
10087
+ errorBodyOverride = null;
9985
10088
  response = await (0, node_fetch_1.default)(...buildFetchArgs());
9986
10089
  if (response.status === 429 && attempt < MAX_RETRIES && canRetry()) {
9987
10090
  reportRetry("Rate limited by the API \u2014 retrying");
@@ -9994,9 +10097,30 @@ var require_client = __commonJS({
9994
10097
  await sleepWithinBudget(backoffMs(attempt));
9995
10098
  continue;
9996
10099
  }
10100
+ if (response.status === 401 || response.status === 403) {
10101
+ const bodyText = await response.text().catch(() => "");
10102
+ if (isExpiredTokenResponse(response.status, bodyText)) {
10103
+ if (!refreshedThisAttempt) {
10104
+ refreshedThisAttempt = true;
10105
+ if (await refreshAccessToken()) {
10106
+ reportRetry("Session expired \u2014 signing you back in");
10107
+ attempt--;
10108
+ continue;
10109
+ }
10110
+ }
10111
+ sessionExpired = true;
10112
+ errorBodyOverride = JSON.stringify({
10113
+ // Replaces the backend's bare "Invalid or expired token", which reads
10114
+ // like a bug rather than something the user can act on.
10115
+ error: "Your session has expired. Run `nex login` to sign in again."
10116
+ });
10117
+ break;
10118
+ }
10119
+ errorBodyOverride = bodyText;
10120
+ }
9997
10121
  const FORBIDDEN_RETRY_LIMIT = 2;
9998
10122
  if (response.status === 403) {
9999
- const bodyText = await response.text().catch(() => "");
10123
+ const bodyText = errorBodyOverride ?? await response.text().catch(() => "");
10000
10124
  let isUpstreamForbidden = false;
10001
10125
  try {
10002
10126
  const parsed = JSON.parse(bodyText);
@@ -10016,35 +10140,22 @@ var require_client = __commonJS({
10016
10140
  errorBodyOverride = bodyText;
10017
10141
  break;
10018
10142
  }
10019
- if (response.status === 410 && resuming) {
10020
- await response.text().catch(() => "");
10021
- await cancelTurn(turnId);
10022
- turnId = (0, crypto_1.randomUUID)();
10023
- resuming = false;
10024
- serverResumable = false;
10025
- lastEventId = 0;
10026
- carryText = [];
10027
- carryToolUse = [];
10028
- const hadRendered = emittedAnythingAcrossAttempts;
10029
- const renderedChars = emittedCharsAcrossAttempts;
10030
- emittedAnythingAcrossAttempts = false;
10031
- emittedCharsAcrossAttempts = 0;
10032
- if (hadRendered && !allowRestartAfterRender) {
10033
- throw new Error("Connection lost and this turn could no longer be resumed. Send your message again.");
10034
- }
10035
- if (hadRendered) {
10036
- onEvent({
10037
- type: "stream_restart",
10038
- reason: "Connection lost too long to resume",
10039
- discardedChars: renderedChars
10040
- });
10041
- }
10042
- if (attempt < MAX_RETRIES && canRetry()) {
10043
- reportRetry("Could not resume \u2014 restarting this turn");
10044
- await sleepWithinBudget(backoffMs(attempt));
10045
- continue;
10143
+ if (resuming && response.status >= 400 && response.status < 500) {
10144
+ const body = errorBodyOverride ?? await response.text().catch(() => "");
10145
+ if (response.status === 410 || response.status === 404 || bodySaysNotResumable(body)) {
10146
+ const canRestart = await prepareRestart("Connection lost too long to resume");
10147
+ if (!canRestart) {
10148
+ throw new Error("Connection lost and this turn could no longer be resumed. Send your message again.");
10149
+ }
10150
+ if (attempt < MAX_RETRIES && canRetry()) {
10151
+ reportRetry("Could not resume \u2014 restarting this turn");
10152
+ await sleepWithinBudget(backoffMs(attempt));
10153
+ continue;
10154
+ }
10155
+ errorBodyOverride = JSON.stringify({ error: "Could not resume this turn \u2014 send your message again." });
10156
+ break;
10046
10157
  }
10047
- errorBodyOverride = JSON.stringify({ error: "Could not resume this turn \u2014 send your message again." });
10158
+ errorBodyOverride = body;
10048
10159
  break;
10049
10160
  }
10050
10161
  if (response.status === 409) {
@@ -10096,7 +10207,12 @@ var require_client = __commonJS({
10096
10207
  } catch {
10097
10208
  errMsg = errText || errMsg;
10098
10209
  }
10099
- throw Object.assign(new Error(errMsg), { status: response.status, balance, ...forbiddenExhausted ? { retryable: true } : {} });
10210
+ throw Object.assign(new Error(errMsg), {
10211
+ status: response.status,
10212
+ balance,
10213
+ ...forbiddenExhausted ? { retryable: true } : {},
10214
+ ...sessionExpired ? { authExpired: true } : {}
10215
+ });
10100
10216
  }
10101
10217
  if (!response.body) {
10102
10218
  throw new Error("Response body is null");
@@ -10321,6 +10437,8 @@ var require_client = __commonJS({
10321
10437
  stream.destroy?.();
10322
10438
  reject(tagTransient(new Error(message)));
10323
10439
  } else {
10440
+ clearInterval(heartbeatWatchdog);
10441
+ stream.destroy?.();
10324
10442
  onEvent({ type: "error", message });
10325
10443
  reject(new Error(message));
10326
10444
  }
@@ -10381,27 +10499,10 @@ var require_client = __commonJS({
10381
10499
  const e2 = err;
10382
10500
  if (e2.retryable && sAttempt < MAX_RETRIES && canRetry()) {
10383
10501
  if (e2.forceRestart) {
10384
- await cancelTurn(turnId);
10385
- turnId = (0, crypto_1.randomUUID)();
10386
- resuming = false;
10387
- serverResumable = false;
10388
- lastEventId = 0;
10389
- carryText = [];
10390
- carryToolUse = [];
10391
- const hadRendered = emittedAnythingAcrossAttempts;
10392
- const renderedChars = emittedCharsAcrossAttempts;
10393
- emittedAnythingAcrossAttempts = false;
10394
- emittedCharsAcrossAttempts = 0;
10395
- if (hadRendered && !allowRestartAfterRender) {
10502
+ const canRestart = await prepareRestart(err.message || "Could not resume \u2014 restarting this turn");
10503
+ if (!canRestart) {
10396
10504
  throw new Error("Connection lost and this turn could no longer be resumed. Send your message again.");
10397
10505
  }
10398
- if (hadRendered) {
10399
- onEvent({
10400
- type: "stream_restart",
10401
- reason: err.message || "Could not resume \u2014 restarting this turn",
10402
- discardedChars: renderedChars
10403
- });
10404
- }
10405
10506
  reportRetry("Could not resume \u2014 restarting this turn");
10406
10507
  await sleepWithinBudget(backoffMs(sAttempt));
10407
10508
  continue;
@@ -10445,11 +10546,30 @@ var require_client = __commonJS({
10445
10546
  } catch {
10446
10547
  }
10447
10548
  }
10549
+ async function revokeRefreshToken2() {
10550
+ const refreshToken = (0, index_1.getRefreshToken)();
10551
+ if (!refreshToken)
10552
+ return;
10553
+ try {
10554
+ await (0, node_fetch_1.default)(`${exports.API_BASE}/api/auth/logout`, {
10555
+ method: "POST",
10556
+ headers: { "Content-Type": "application/json" },
10557
+ body: JSON.stringify({ refreshToken })
10558
+ });
10559
+ } catch {
10560
+ }
10561
+ }
10448
10562
  async function getBalance3() {
10449
- const response = await (0, node_fetch_1.default)(`${exports.API_BASE}/api/code/balance`, {
10450
- method: "GET",
10451
- headers: authHeaders()
10452
- });
10563
+ const fetchOnce = () => (0, node_fetch_1.default)(`${exports.API_BASE}/api/code/balance`, { method: "GET", headers: authHeaders() });
10564
+ let response = await fetchOnce();
10565
+ if (response.status === 401 || response.status === 403) {
10566
+ const body = await response.text().catch(() => "");
10567
+ if (isExpiredTokenResponse(response.status, body) && await refreshAccessToken()) {
10568
+ response = await fetchOnce();
10569
+ } else {
10570
+ throw new Error(`API error ${response.status}: ${body}`);
10571
+ }
10572
+ }
10453
10573
  if (!response.ok) {
10454
10574
  const errText = await response.text();
10455
10575
  throw new Error(`API error ${response.status}: ${errText}`);
@@ -10470,7 +10590,11 @@ var require_client = __commonJS({
10470
10590
  const data = await response.json();
10471
10591
  if (!data.token)
10472
10592
  throw new Error("No token in exchange response");
10473
- return { token: data.token, email: data.user?.email ?? "" };
10593
+ return {
10594
+ token: data.token,
10595
+ ...data.refreshToken ? { refreshToken: data.refreshToken } : {},
10596
+ email: data.user?.email ?? ""
10597
+ };
10474
10598
  }
10475
10599
  async function login2(email, password) {
10476
10600
  const response = await (0, node_fetch_1.default)(`${exports.API_BASE}/api/auth/login/email`, {
@@ -10486,7 +10610,11 @@ var require_client = __commonJS({
10486
10610
  if (!data.token) {
10487
10611
  throw new Error("Login response missing token");
10488
10612
  }
10489
- return { token: data.token, email: data.email ?? email };
10613
+ return {
10614
+ token: data.token,
10615
+ ...data.refreshToken ? { refreshToken: data.refreshToken } : {},
10616
+ email: data.email ?? email
10617
+ };
10490
10618
  }
10491
10619
  }
10492
10620
  });
@@ -10691,6 +10819,144 @@ var require_editCompleteness = __commonJS({
10691
10819
  }
10692
10820
  });
10693
10821
 
10822
+ // ../core/dist/agent/securityLint.js
10823
+ var require_securityLint = __commonJS({
10824
+ "../core/dist/agent/securityLint.js"(exports) {
10825
+ "use strict";
10826
+ Object.defineProperty(exports, "__esModule", { value: true });
10827
+ exports.checkSecurity = checkSecurity;
10828
+ exports.securityNoteText = securityNoteText;
10829
+ var SAMPLE_MAX = 160;
10830
+ function redact(line) {
10831
+ const masked = line.replace(/(['"`]?[\w.-]*(?:secret|password|passwd|token|api[_-]?key|apikey|auth|credential|private[_-]?key)[\w.-]*['"`]?\s*[:=]\s*)(['"`])([^'"`]{4,})\2/gi, (_m, head, q) => `${head}${q}[REDACTED]${q}`).replace(/\b(sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,})\b/g, "[REDACTED]");
10832
+ return masked.length > SAMPLE_MAX ? masked.slice(0, SAMPLE_MAX) + "\u2026" : masked;
10833
+ }
10834
+ function isLikelyCommentLine(line) {
10835
+ return /^\s*(\/\/|\*|#|--|<!--)/.test(line);
10836
+ }
10837
+ var RULES = [
10838
+ // ── Hardcoded credentials ───────────────────────────────────────────────────
10839
+ // Provider-prefixed tokens are near-zero false positive: the prefixes are
10840
+ // registered formats, not something that occurs naturally in source. Checked
10841
+ // inside comments too — a key commented out is still a committed key.
10842
+ {
10843
+ kind: "hardcoded-secret",
10844
+ re: /\b(sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,})\b/,
10845
+ message: "Looks like a real API key/token committed to source. Move it to an environment variable and rotate the exposed key.",
10846
+ includeComments: true
10847
+ },
10848
+ // NOTE: the `private-key` check is NOT here — it needs to span multiple lines
10849
+ // (BEGIN header on one, base64 body on the next), which this per-line loop
10850
+ // cannot express. It runs separately in checkSecurity below.
10851
+ {
10852
+ kind: "hardcoded-password",
10853
+ // An ASSIGNMENT of a credential-ish name to a non-trivial literal.
10854
+ //
10855
+ // Tightened after measuring against the real backend, where the looser version
10856
+ // fired on `missingSecret:'FIREBASE_TOKEN'` — code that NAMES a secret in an
10857
+ // error message, the opposite of leaking one. So a value that is itself just a
10858
+ // SCREAMING_SNAKE identifier (an env-var name) is excluded, along with the
10859
+ // usual placeholder vocabulary. The value must also look like actual secret
10860
+ // material: mixed case or digits, not a lone lowercase word.
10861
+ re: /(?:password|passwd|secret|api[_-]?key|apikey|access[_-]?token)['"`]?\s*[:=]\s*['"`](?![A-Z0-9_]+['"`])(?!.*(?:\$\{|process\.env|os\.environ|example|changeme|placeholder|redacted|xxx|test|dummy|fake|sample|your[_-]?|<|\*{3}))(?=[^'"`]*[0-9A-Z])[^'"`\s]{10,}['"`]/,
10862
+ message: "Hardcoded credential literal. Read it from the environment/secret store instead, and rotate the exposed value.",
10863
+ includeComments: true
10864
+ },
10865
+ // ── Injection ───────────────────────────────────────────────────────────────
10866
+ {
10867
+ kind: "dynamic-eval",
10868
+ // The negative lookbehind for `.` is what makes this usable: `redisClient.eval`
10869
+ // (a Redis Lua script), `page.eval` (Playwright), `vm.eval` and friends are
10870
+ // METHOD calls on an object and have nothing to do with JavaScript's global
10871
+ // eval. Without it, the real backend's Redis idempotency script was flagged.
10872
+ // Only a bare `eval(` / `new Function(` with a non-literal argument counts.
10873
+ re: /(?<![.\w$])(?:eval|new\s+Function)\s*\(\s*(?!['"`][^'"`]*['"`]\s*\))[^)]*[a-zA-Z_$][\w$]*/,
10874
+ message: "eval / new Function on a non-literal value executes arbitrary code if that value is ever user-controlled. Use an explicit parser or a lookup table."
10875
+ },
10876
+ {
10877
+ kind: "sql-injection",
10878
+ // Only fires when the interpolated expression is plausibly REQUEST-DERIVED.
10879
+ //
10880
+ // The obvious pattern — any `${...}` inside a SQL string — was measured
10881
+ // against the real backend and flagged 15 of 183 files, essentially all of
10882
+ // them safe and idiomatic: `${sets.join(', ')}` for a dynamic UPDATE, `${field}`
10883
+ // for a server-chosen column, `${CONSUMPTION}` for a module constant. At that
10884
+ // hit rate the warning is pure noise, and noise is worse than silence because
10885
+ // it teaches everyone to skip the channel.
10886
+ //
10887
+ // So the interpolation must name something that plausibly came from the
10888
+ // outside: req/request/params/query/body/input/user/args, or a bare
10889
+ // `'...' + ident`. This trades recall for precision on purpose — thorough SQL
10890
+ // review is the security-auditor agent's job, not an inline regex's.
10891
+ re: /\b(?:SELECT|INSERT\s+INTO|UPDATE|DELETE\s+FROM)\b[^;'"`]{0,160}(?:\$\{\s*(?:req|request|params?|query|body|input|user|args|ctx)\b|['"`]\s*\+\s*(?:req|request|params?|query|body|input|user|args|ctx)\b|%\s*\(\s*(?:request|params?|query|body|input|user)\b)/i,
10892
+ message: "SQL built by interpolating a request-derived value. Use a parameterised query ($1 / ? placeholders) \u2014 this is the classic injection sink."
10893
+ },
10894
+ {
10895
+ kind: "command-injection",
10896
+ // Shell execution with an interpolated or concatenated argument.
10897
+ re: /\b(?:exec|execSync|spawnSync?|system|popen|os\.system|subprocess\.(?:call|run|Popen))\s*\(\s*(?:[`'"][^`'"]*(?:\$\{|['"]\s*\+)|[a-zA-Z_$][\w$]*\s*\+)/,
10898
+ message: "Shell command built from a variable. Pass arguments as an array (no shell), or validate against an allowlist \u2014 a value containing ; or $() becomes command execution."
10899
+ },
10900
+ // ── Transport / verification ────────────────────────────────────────────────
10901
+ {
10902
+ kind: "tls-verification-disabled",
10903
+ re: /(?:rejectUnauthorized\s*:\s*false|NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*['"]?0|verify\s*=\s*False|InsecureSkipVerify\s*:\s*true)/,
10904
+ message: "TLS certificate verification is disabled, which removes protection against man-in-the-middle attacks. Trust a specific CA instead if the cert is self-signed."
10905
+ }
10906
+ ];
10907
+ function checkSecurity(content, max = 5) {
10908
+ if (!content)
10909
+ return [];
10910
+ const findings = [];
10911
+ const lines = content.split(/\r?\n/);
10912
+ const pemIdx = lines.findIndex((l) => /-----BEGIN\s+(?:RSA|EC|DSA|OPENSSH|PGP)?\s*PRIVATE KEY-----/.test(l));
10913
+ if (pemIdx !== -1) {
10914
+ const following = lines.slice(pemIdx, pemIdx + 4).join("\n");
10915
+ if (/[A-Za-z0-9+/]{40,}/.test(following.replace(/-----[^-]+-----/g, ""))) {
10916
+ findings.push({
10917
+ kind: "private-key",
10918
+ message: "A private key with real key material is being written into source. Store it outside the repo (secret manager / env var) and rotate it.",
10919
+ line: pemIdx + 1,
10920
+ sample: "-----BEGIN PRIVATE KEY----- [REDACTED]"
10921
+ });
10922
+ }
10923
+ }
10924
+ const seenKinds = /* @__PURE__ */ new Set();
10925
+ for (let i2 = 0; i2 < lines.length && findings.length < max; i2++) {
10926
+ const line = lines[i2];
10927
+ if (!line || line.length > 2e3)
10928
+ continue;
10929
+ const commentish = isLikelyCommentLine(line);
10930
+ for (const rule of RULES) {
10931
+ if (seenKinds.has(rule.kind))
10932
+ continue;
10933
+ if (commentish && !rule.includeComments)
10934
+ continue;
10935
+ if (!rule.re.test(line))
10936
+ continue;
10937
+ seenKinds.add(rule.kind);
10938
+ findings.push({ kind: rule.kind, message: rule.message, line: i2 + 1, sample: redact(line.trim()) });
10939
+ break;
10940
+ }
10941
+ }
10942
+ return findings;
10943
+ }
10944
+ function securityNoteText(findings) {
10945
+ if (process.env.NEXRALL_SECURITY_LINT === "off")
10946
+ return "";
10947
+ if (!findings.length)
10948
+ return "";
10949
+ const lines = findings.map((f3) => ` \u2022 line ${f3.line} [${f3.kind}]: ${f3.message}
10950
+ ${f3.sample}`);
10951
+ return `
10952
+
10953
+ \u26A0 SECURITY REVIEW (${findings.length} finding${findings.length > 1 ? "s" : ""}) \u2014 the write succeeded; check these before moving on:
10954
+ ` + lines.join("\n") + `
10955
+ If a finding is a false positive (test fixture, placeholder, intentionally dynamic), say so and continue \u2014 do NOT rewrite correct code to silence it.`;
10956
+ }
10957
+ }
10958
+ });
10959
+
10694
10960
  // ../core/dist/agent/memory.js
10695
10961
  var require_memory = __commonJS({
10696
10962
  "../core/dist/agent/memory.js"(exports) {
@@ -12354,6 +12620,7 @@ var require_executor = __commonJS({
12354
12620
  var sandbox_1 = require_sandbox();
12355
12621
  var auth_1 = require_auth();
12356
12622
  var editCompleteness_1 = require_editCompleteness();
12623
+ var securityLint_1 = require_securityLint();
12357
12624
  var memory_1 = require_memory();
12358
12625
  var skills_1 = require_skills();
12359
12626
  var testIntegrity_1 = require_testIntegrity();
@@ -12606,24 +12873,19 @@ Resolve the conflict (remove <<<<<<< / ======= / >>>>>>> markers and keep the in
12606
12873
  } catch {
12607
12874
  }
12608
12875
  }
12609
- fs9.writeFileSync(resolved, content, "utf-8");
12610
- if (existingMode !== void 0) {
12611
- try {
12612
- fs9.chmodSync(resolved, existingMode);
12613
- } catch {
12614
- }
12615
- }
12876
+ atomicWrite(resolved, content, existingMode);
12616
12877
  const bytes = Buffer.byteLength(content, "utf-8");
12617
12878
  const lines = content.split("\n").length;
12879
+ const sec = (0, securityLint_1.securityNoteText)((0, securityLint_1.checkSecurity)(content));
12618
12880
  if (isNew) {
12619
- return { output: `Created ${resolved} (${lines} lines, ${bytes} bytes)` };
12881
+ return { output: `Created ${resolved} (${lines} lines, ${bytes} bytes)${sec}` };
12620
12882
  }
12621
12883
  const xfile = crossFileBreakageWarning(resolved, normalizeLF(priorContent), normalizeLF(content), workDir ?? process.cwd());
12622
12884
  let tiMarker = "";
12623
12885
  const ti = (0, testIntegrity_1.analyzeTestEdit)(resolved, normalizeLF(priorContent), normalizeLF(content));
12624
12886
  if (ti.suspicious)
12625
12887
  tiMarker = (0, testIntegrity_1.encodeTestIntegrityMarker)(ti.findings);
12626
- return { output: `Overwrote ${resolved} (${lines} lines, ${bytes} bytes)${xfile}${tiMarker}` };
12888
+ return { output: `Overwrote ${resolved} (${lines} lines, ${bytes} bytes)${xfile}${sec}${tiMarker}` };
12627
12889
  } catch (err) {
12628
12890
  return { error: err.message };
12629
12891
  }
@@ -13326,14 +13588,52 @@ ${globalCapMatches(output)}` : "";
13326
13588
  }
13327
13589
  return { mode: stat2.mode, content: fs9.readFileSync(resolved, "utf-8") };
13328
13590
  }
13329
- function atomicWritePreservingMode(resolved, data, mode) {
13330
- const tmp = resolved + ".nexrall_tmp";
13331
- fs9.writeFileSync(tmp, data, "utf-8");
13591
+ function atomicWrite(resolved, data, mode) {
13592
+ let target = resolved;
13332
13593
  try {
13333
- fs9.chmodSync(tmp, mode);
13594
+ if (fs9.lstatSync(resolved).isSymbolicLink())
13595
+ target = fs9.realpathSync(resolved);
13334
13596
  } catch {
13335
13597
  }
13336
- fs9.renameSync(tmp, resolved);
13598
+ const tmp = `${target}.nexrall_tmp_${process.pid}`;
13599
+ try {
13600
+ fs9.writeFileSync(tmp, data, "utf-8");
13601
+ } catch (err) {
13602
+ const code = err.code;
13603
+ if (code === "EACCES" || code === "EPERM" || code === "EROFS") {
13604
+ fs9.writeFileSync(target, data, "utf-8");
13605
+ if (mode !== void 0) {
13606
+ try {
13607
+ fs9.chmodSync(target, mode);
13608
+ } catch {
13609
+ }
13610
+ }
13611
+ return;
13612
+ }
13613
+ try {
13614
+ fs9.rmSync(tmp, { force: true });
13615
+ } catch {
13616
+ }
13617
+ throw err;
13618
+ }
13619
+ try {
13620
+ if (mode !== void 0) {
13621
+ try {
13622
+ fs9.chmodSync(tmp, mode);
13623
+ } catch {
13624
+ }
13625
+ }
13626
+ fs9.renameSync(tmp, target);
13627
+ } catch (err) {
13628
+ try {
13629
+ fs9.rmSync(tmp, { force: true });
13630
+ } catch {
13631
+ }
13632
+ throw err;
13633
+ }
13634
+ }
13635
+ function atomicWritePreservingMode(resolved, data, mode) {
13636
+ atomicWrite(resolved, data, mode);
13337
13637
  }
13338
13638
  function normalizeLF(s2) {
13339
13639
  return s2.replace(/\r\n/g, "\n");
@@ -13371,9 +13671,10 @@ ${globalCapMatches(output)}` : "";
13371
13671
  const linesBefore = origNorm.split("\n").length;
13372
13672
  const linesAfter = updated.split("\n").length;
13373
13673
  const xfile = crossFileBreakageWarning(resolved, origNorm, normalizeLF(updated), workDir ?? process.cwd());
13674
+ const sec = (0, securityLint_1.securityNoteText)((0, securityLint_1.checkSecurity)(newNorm));
13374
13675
  return { output: `Edited ${resolved} (${linesBefore} \u2192 ${linesAfter} lines)
13375
13676
 
13376
- ${diff3}${xfile}` };
13677
+ ${diff3}${xfile}${sec}` };
13377
13678
  } catch (err) {
13378
13679
  return { error: err.message };
13379
13680
  }
@@ -13641,6 +13942,7 @@ ${body}${truncNote}
13641
13942
  let content = normalizeLF(rawFile);
13642
13943
  const originalNorm = content;
13643
13944
  const diffs = [];
13945
+ const insertedText = [];
13644
13946
  for (let i2 = 0; i2 < edits.length; i2++) {
13645
13947
  const edit = edits[i2];
13646
13948
  const oldStr = normalizeLF(typeof edit.old_string === "string" ? edit.old_string : "");
@@ -13655,16 +13957,18 @@ ${body}${truncNote}
13655
13957
  return { error: `Edit #${i2 + 1}: old_string appears ${count} times \u2014 it must be unique. Add more surrounding context.` };
13656
13958
  }
13657
13959
  diffs.push(buildDiff(filePath, oldStr, newStr, content));
13960
+ insertedText.push(newStr);
13658
13961
  content = literalReplace(content, oldStr, newStr);
13659
13962
  }
13660
13963
  const finalContent = wasCRLF ? content.replace(/\n/g, "\r\n") : content;
13661
13964
  atomicWritePreservingMode(resolved, finalContent, pre.mode);
13662
13965
  const xfile = crossFileBreakageWarning(resolved, originalNorm, content, workDir ?? process.cwd());
13966
+ const sec = (0, securityLint_1.securityNoteText)((0, securityLint_1.checkSecurity)(insertedText.join("\n")));
13663
13967
  return {
13664
13968
  output: `Applied ${edits.length} edit(s) to ${resolved}:
13665
13969
  ` + diffs.map((d, i2) => `
13666
13970
  --- edit #${i2 + 1} ---
13667
- ${d}`).join("\n") + xfile
13971
+ ${d}`).join("\n") + xfile + sec
13668
13972
  };
13669
13973
  } catch (err) {
13670
13974
  return { error: err.message };
@@ -14245,11 +14549,40 @@ var require_agentTypes = __commonJS({
14245
14549
  var path6 = __importStar(__require("path"));
14246
14550
  var os6 = __importStar(__require("os"));
14247
14551
  var index_1 = require_plugins();
14552
+ var READ_ONLY_TOOLS = [
14553
+ // Universal
14554
+ "read_file",
14555
+ "search_files",
14556
+ "glob",
14557
+ "list_directory",
14558
+ "bash",
14559
+ "bash_output",
14560
+ "notebook_read",
14561
+ "todo_write",
14562
+ "todo_read",
14563
+ // VS Code language server (ignored on the CLI)
14564
+ "get_symbols",
14565
+ "get_workspace_symbols",
14566
+ "find_references",
14567
+ "go_to_definition",
14568
+ "get_hover",
14569
+ "get_diagnostics"
14570
+ ];
14571
+ var RESEARCH_TOOLS = [...READ_ONLY_TOOLS, "web_search", "fetch_url"];
14572
+ var WRITE_TOOLS = [
14573
+ ...READ_ONLY_TOOLS,
14574
+ "write_file",
14575
+ "edit_file",
14576
+ "multi_edit",
14577
+ "create_directory",
14578
+ "move_file",
14579
+ "copy_file"
14580
+ ];
14248
14581
  var BUILTIN_AGENTS = [
14249
14582
  {
14250
14583
  name: "reviewer",
14251
14584
  description: "Read-only code reviewer \u2014 finds correctness bugs, edge cases, and security issues in a diff or file set. Cannot modify files.",
14252
- tools: ["read_file", "search_files", "glob", "list_directory", "bash", "get_symbols", "get_workspace_symbols", "find_references", "go_to_definition", "get_diagnostics"],
14585
+ tools: READ_ONLY_TOOLS,
14253
14586
  source: "builtin",
14254
14587
  prompt: [
14255
14588
  "You are a meticulous senior code reviewer. You NEVER modify files \u2014 you only read, search, and report.",
@@ -14265,6 +14598,162 @@ var require_agentTypes = __commonJS({
14265
14598
  "Report format: \u{1F534} Critical / \u{1F7E1} Warning / \u{1F7E2} Suggestion, each with file:line and a concrete fix,",
14266
14599
  "then a final verdict (APPROVE or REQUEST CHANGES) with a one-paragraph rationale."
14267
14600
  ].join("\n")
14601
+ },
14602
+ // Promoted from the security-audit plugin to a builtin.
14603
+ //
14604
+ // Leaving it plugin-only was indefensible next to `reviewer` being builtin:
14605
+ // reviewer's own prompt already tells it to look for security issues, so
14606
+ // security IS treated as default work — yet the specialist agent for it was
14607
+ // invisible unless the user happened to know the plugin existed. For an agent
14608
+ // that WRITES code, "you only get a security review if you knew to install
14609
+ // something" is the wrong default.
14610
+ {
14611
+ name: "security-auditor",
14612
+ description: "Read-only security auditor \u2014 hunts injection, authz, secrets, and validation flaws in a path or diff. Cannot modify files.",
14613
+ tools: READ_ONLY_TOOLS,
14614
+ model: "pro",
14615
+ source: "builtin",
14616
+ prompt: [
14617
+ "You are a security auditor. You find real, exploitable flaws \u2014 not style issues.",
14618
+ "",
14619
+ "Method:",
14620
+ "1. Map the attack surface FIRST: entry points (HTTP routes, message handlers, CLI args, file/network",
14621
+ " input, deserialization), then trace user-controlled data inward to where it is used.",
14622
+ "2. For each finding: file:line, the flaw class, a one-line exploit scenario, and the concrete fix.",
14623
+ "3. Grade severity honestly: Critical = remote compromise or data breach; High = auth bypass/IDOR;",
14624
+ " Medium = needs unusual preconditions; Low = hardening.",
14625
+ "",
14626
+ "Classes worth the most attention, in order: injection (SQL/command/template/prototype), broken",
14627
+ "authz (missing ownership checks, IDOR, trusting client-supplied ids), secrets committed to source,",
14628
+ "path traversal, SSRF, unsafe deserialization, missing rate limits on expensive or auth endpoints,",
14629
+ "and crypto misuse (hand-rolled comparison, predictable randomness).",
14630
+ "",
14631
+ "Hard rules:",
14632
+ "- READ-ONLY: never modify, create or delete files. bash only for read-only inspection.",
14633
+ "- NEVER print a discovered secret's value. Report its location and advise rotation.",
14634
+ "- Distinguish EXPLOITABLE from theoretical, and say which one each finding is.",
14635
+ '- "No issues found in scope X" is a valid, useful result. Do not pad the report to look thorough.'
14636
+ ].join("\n")
14637
+ },
14638
+ // The gap Claude Code fills with its built-in `Explore`: read-heavy codebase
14639
+ // search that would otherwise flood the parent's context. Defaults to the
14640
+ // cheapest model on purpose — "find every caller of X" has no need of a
14641
+ // frontier model, and this is the agent most likely to be spawned in bulk.
14642
+ {
14643
+ name: "explorer",
14644
+ description: "Fast read-only codebase explorer \u2014 locates files, symbols, and call sites and reports concise findings. Use to keep bulk searching out of the main context. Cannot modify files.",
14645
+ tools: READ_ONLY_TOOLS,
14646
+ model: "turbo",
14647
+ source: "builtin",
14648
+ prompt: [
14649
+ "You map code. You NEVER modify anything.",
14650
+ "",
14651
+ "Method:",
14652
+ "1. Prefer structural search over text search where available (get_workspace_symbols, find_references,",
14653
+ " go_to_definition); fall back to search_files/glob otherwise.",
14654
+ "2. Read only the sections you need \u2014 use read_file with offset/limit on large files instead of",
14655
+ " pulling in thousands of lines.",
14656
+ "3. Follow the real call graph rather than guessing from names.",
14657
+ "",
14658
+ "Your ONLY output is a compact report: the file:line locations that matter, how they relate, and the",
14659
+ "direct answer to the question you were given. This exists to keep bulk search OUT of the parent's",
14660
+ "context, so do not paste large file contents back \u2014 cite locations and summarise. Say plainly when",
14661
+ 'something does not exist; a confident wrong answer is far worse than "not found".'
14662
+ ].join("\n")
14663
+ },
14664
+ // Matches Claude Code's built-in `Plan`: research a change and return a
14665
+ // strategy, deliberately WITHOUT write access so "make a plan" can never
14666
+ // quietly become "start editing".
14667
+ {
14668
+ name: "planner",
14669
+ description: "Read-only planning agent \u2014 researches a change and returns a concrete step-by-step implementation plan with risks and affected files. Cannot modify files.",
14670
+ tools: RESEARCH_TOOLS,
14671
+ model: "pro",
14672
+ source: "builtin",
14673
+ prompt: [
14674
+ "You produce implementation plans. You NEVER modify files \u2014 planning and doing are separate steps,",
14675
+ 'and this agent exists so "plan it" cannot silently turn into "change it".',
14676
+ "",
14677
+ "Method:",
14678
+ "1. Read the actual code before proposing anything. No plan may rest on an assumed API shape.",
14679
+ "2. Find every affected call site (find_references / search_files) and list them.",
14680
+ "3. Order the steps so the tree stays working after each one \u2014 types, then implementation, then",
14681
+ " tests, then exports/registration.",
14682
+ "",
14683
+ "Output:",
14684
+ "- Goal, in one sentence.",
14685
+ "- Numbered steps, each with the exact files touched and what changes in them.",
14686
+ "- Risks + the specific thing that could break, and how it would be detected.",
14687
+ "- How to verify (the exact test/build command for THIS project, taken from package.json/Makefile).",
14688
+ "- Anything genuinely ambiguous, stated as an open question rather than a silent assumption."
14689
+ ].join("\n")
14690
+ },
14691
+ // Promoted from the test-gen plugin. Needs write access — it produces test
14692
+ // files — but is deliberately forbidden from touching source, because "make the
14693
+ // tests pass" is the single most common way an agent destroys signal.
14694
+ {
14695
+ name: "test-writer",
14696
+ description: "Writes tests that follow the project's existing conventions. May create/edit TEST files only \u2014 never production source.",
14697
+ tools: WRITE_TOOLS,
14698
+ // Enforced, not merely requested: the permission gate refuses a write whose
14699
+ // path is not a test file. Without this the allowlist would grant edit_file
14700
+ // for every path and the rule below would be a suggestion the model is free
14701
+ // to rationalise its way past.
14702
+ testFilesOnly: true,
14703
+ source: "builtin",
14704
+ prompt: [
14705
+ "You write tests. You may create and edit TEST files only.",
14706
+ "",
14707
+ "Hard rules \u2014 these are the ways test-writing agents destroy value, so they are non-negotiable:",
14708
+ "- NEVER modify production source to make a test pass. If the code looks wrong, REPORT it and stop.",
14709
+ "- NEVER weaken, delete or skip an existing assertion or test.",
14710
+ "- A test that cannot fail is worse than no test. Every test must be able to fail for one clear reason.",
14711
+ "",
14712
+ "Method:",
14713
+ "1. Read the existing tests FIRST and copy their conventions exactly \u2014 runner, file naming, layout,",
14714
+ " assertion style, fixture/helper patterns. Never introduce a new framework.",
14715
+ "2. Test observable behaviour and the contract, not private internals.",
14716
+ "3. Cover the boring-but-real cases: empty input, null/undefined, unicode and non-BMP characters,",
14717
+ " boundaries, error paths, concurrency where it applies.",
14718
+ "4. No sleeps or wall-clock dependence \u2014 those produce the flaky tests that get deleted later.",
14719
+ "5. RUN the tests you wrote and report the real output. Never claim a test passes without running it."
14720
+ ].join("\n")
14721
+ },
14722
+ // The DevOps gap — answered with a READ-ONLY advisor, not an operator.
14723
+ //
14724
+ // A "DevOps agent" with write/apply access is a genuinely different risk class
14725
+ // from the others here: its mistakes are `kubectl delete`, a bad `terraform
14726
+ // apply`, a broken deploy pipeline — often not revertible and affecting
14727
+ // production rather than a working tree. So this one diagnoses and proposes a
14728
+ // diff; a human applies it. That asymmetry is the whole design.
14729
+ {
14730
+ name: "devops-advisor",
14731
+ description: "Read-only CI/CD, container, and infrastructure advisor \u2014 diagnoses pipelines, Dockerfiles, and k8s manifests and proposes concrete fixes as a diff. Never applies changes.",
14732
+ tools: RESEARCH_TOOLS,
14733
+ model: "pro",
14734
+ source: "builtin",
14735
+ prompt: [
14736
+ "You are an infrastructure and delivery advisor. You DIAGNOSE and PROPOSE. You never apply changes.",
14737
+ "",
14738
+ "Hard rules:",
14739
+ "- READ-ONLY, and stricter than the other read-only agents: bash is for INSPECTION only",
14740
+ " (git log/diff, cat, grep, `kubectl get/describe`, `docker images`, `terraform plan`).",
14741
+ " NEVER run anything that mutates infrastructure \u2014 no apply/delete/scale/rollout/restart/push,",
14742
+ " no `terraform apply`, no `helm upgrade`. If a fix needs such a command, WRITE IT OUT for a human.",
14743
+ "- Never print secret values from env files, k8s Secrets or CI variables. Reference them by name.",
14744
+ "",
14745
+ "Method:",
14746
+ "1. Read what actually exists \u2014 workflow files, Dockerfiles, manifests, kustomize overlays, the",
14747
+ ' deploy scripts \u2014 before drawing any conclusion. Never reason from what a stack "usually" looks like.',
14748
+ "2. Follow the real path a change takes to production, and name the step that is broken or missing.",
14749
+ "3. Check the failure modes that bite hardest: CI path filters that skip files a workload actually",
14750
+ " needs, image tags that do not match what is deployed, missing health probes, absent resource",
14751
+ " limits, secrets baked into images, ports/timeouts inconsistent between proxy and app, and",
14752
+ " migrations that must run before the new image is live.",
14753
+ "",
14754
+ "Output: the diagnosis, the evidence (file:line or command output), the proposed change as a diff or",
14755
+ "exact file content, and the command a human should run to apply and verify it."
14756
+ ].join("\n")
14268
14757
  }
14269
14758
  ];
14270
14759
  function parseFrontmatter(raw) {
@@ -14283,6 +14772,9 @@ var require_agentTypes = __commonJS({
14283
14772
  const s2 = (v ?? "").toLowerCase();
14284
14773
  return s2 === "turbo" || s2 === "pro" || s2 === "ultra" ? s2 : void 0;
14285
14774
  }
14775
+ function parseBool(v) {
14776
+ return /^(true|yes|1|on)$/i.test((v ?? "").trim());
14777
+ }
14286
14778
  function parseToolList(v) {
14287
14779
  if (!v)
14288
14780
  return void 0;
@@ -14311,7 +14803,12 @@ var require_agentTypes = __commonJS({
14311
14803
  tools: parseToolList(meta.tools),
14312
14804
  model: parseModel(meta.model),
14313
14805
  prompt: body,
14314
- source
14806
+ source,
14807
+ // Exposed to user/plugin definitions too — `test_files_only: true` (or
14808
+ // `testFilesOnly`) lets anyone build a test-writing agent that genuinely
14809
+ // cannot touch production source, rather than only the builtin getting
14810
+ // that guarantee.
14811
+ ...parseBool(meta.test_files_only ?? meta.testfilesonly) ? { testFilesOnly: true } : {}
14315
14812
  });
14316
14813
  } catch {
14317
14814
  }
@@ -14327,16 +14824,38 @@ var require_agentTypes = __commonJS({
14327
14824
  if (!out.has(agent.name))
14328
14825
  out.set(agent.name, agent);
14329
14826
  }
14330
- return [...out.values()];
14827
+ const builtinOrder = new Map(BUILTIN_AGENTS.map((a, i2) => [a.name, i2]));
14828
+ return [...out.values()].sort((a, b) => {
14829
+ const ai = builtinOrder.get(a.name);
14830
+ const bi = builtinOrder.get(b.name);
14831
+ if (ai !== void 0 && bi !== void 0)
14832
+ return ai - bi;
14833
+ if (ai !== void 0)
14834
+ return -1;
14835
+ if (bi !== void 0)
14836
+ return 1;
14837
+ return a.name.localeCompare(b.name);
14838
+ });
14331
14839
  }
14332
14840
  function summariseAgents(types3) {
14333
14841
  if (!types3.length)
14334
14842
  return "";
14335
14843
  return types3.map((t2) => {
14336
- const tools = t2.tools ? ` (tools: ${t2.tools.join(", ")})` : "";
14337
- return `- ${t2.name}: ${t2.description}${tools}`;
14844
+ const canWrite = !t2.tools || t2.tools.some((x2) => WRITE_TOOL_HINTS.has(x2));
14845
+ const access = t2.testFilesOnly ? "writes TEST files only" : canWrite ? "can modify files" : "read-only";
14846
+ const model = t2.model ? `, ${t2.model} model` : "";
14847
+ return `- ${t2.name} (${access}${model}): ${t2.description}`;
14338
14848
  }).join("\n");
14339
14849
  }
14850
+ var WRITE_TOOL_HINTS = /* @__PURE__ */ new Set([
14851
+ "write_file",
14852
+ "edit_file",
14853
+ "multi_edit",
14854
+ "notebook_edit",
14855
+ "delete_file",
14856
+ "move_file",
14857
+ "copy_file"
14858
+ ]);
14340
14859
  function findAgentType(types3, name) {
14341
14860
  if (!name)
14342
14861
  return void 0;
@@ -14738,9 +15257,14 @@ var require_loop = __commonJS({
14738
15257
  Object.defineProperty(exports, "__esModule", { value: true });
14739
15258
  exports.VERIFY_CMD_RE = exports.WRITE_TOOL_NAMES = void 0;
14740
15259
  exports.resolveMaxIterations = resolveMaxIterations;
15260
+ exports.createLimiter = createLimiter;
15261
+ exports.extractSubTaskText = extractSubTaskText;
15262
+ exports.capSubTaskText = capSubTaskText;
15263
+ exports.summariseSubTaskProgress = summariseSubTaskProgress;
14741
15264
  exports.contextWindowFor = contextWindowFor2;
14742
15265
  exports.compactionThresholds = compactionThresholds2;
14743
15266
  exports.estimateBodyBytes = estimateBodyBytes2;
15267
+ exports.allowsTestOnlyWrite = allowsTestOnlyWrite;
14744
15268
  exports.findSafeCutIndex = findSafeCutIndex;
14745
15269
  exports.transcriptOf = transcriptOf;
14746
15270
  exports.createLedger = createLedger;
@@ -14900,6 +15424,29 @@ var require_loop = __commonJS({
14900
15424
  _fileLocks.delete(absPath);
14901
15425
  }
14902
15426
  }
15427
+ var MAX_CONCURRENT_SUBTASKS = (() => {
15428
+ const raw = Number(process.env.NEXRALL_MAX_CONCURRENT_SUBTASKS);
15429
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 4;
15430
+ })();
15431
+ function createLimiter(max) {
15432
+ let active = 0;
15433
+ const queue = [];
15434
+ const release2 = () => {
15435
+ active--;
15436
+ queue.shift()?.();
15437
+ };
15438
+ return async (fn) => {
15439
+ if (active >= max)
15440
+ await new Promise((resolve3) => queue.push(resolve3));
15441
+ active++;
15442
+ try {
15443
+ return await fn();
15444
+ } finally {
15445
+ release2();
15446
+ }
15447
+ };
15448
+ }
15449
+ var _subTaskLimit = createLimiter(MAX_CONCURRENT_SUBTASKS);
14903
15450
  function humanDescription(name, input) {
14904
15451
  switch (name) {
14905
15452
  case "read_file":
@@ -14981,16 +15528,71 @@ var require_loop = __commonJS({
14981
15528
  return `Use tool: ${name}`;
14982
15529
  }
14983
15530
  }
14984
- var MAX_TASK_DEPTH = 2;
15531
+ var MAX_TASK_DEPTH = 1;
14985
15532
  var _subTaskCounter = 0;
14986
15533
  var SUBTASK_TIMEOUT_MS = Number(process.env.NEXRALL_SUBTASK_TIMEOUT_MS) > 0 ? Number(process.env.NEXRALL_SUBTASK_TIMEOUT_MS) : 10 * 60 * 1e3;
15534
+ var SUBTASK_MAX = 48e3;
15535
+ function sliceSafeEnd(s2, max) {
15536
+ if (s2.length <= max)
15537
+ return s2;
15538
+ let end = max;
15539
+ const code = s2.charCodeAt(end - 1);
15540
+ if (code >= 55296 && code <= 56319)
15541
+ end--;
15542
+ return s2.slice(0, end);
15543
+ }
15544
+ function sliceSafeStart(s2, from) {
15545
+ if (from <= 0)
15546
+ return s2;
15547
+ let start = from;
15548
+ const code = s2.charCodeAt(start);
15549
+ if (code >= 56320 && code <= 57343)
15550
+ start++;
15551
+ return s2.slice(start);
15552
+ }
15553
+ function extractSubTaskText(messages, preferLast = true) {
15554
+ const assistants = messages.filter((m2) => m2.role === "assistant");
15555
+ const textOf = (m2) => (m2?.content ?? []).filter((b) => b.type === "text" && typeof b.text === "string").map((b) => b.text).join("").trim();
15556
+ if (preferLast)
15557
+ return textOf(assistants[assistants.length - 1]);
15558
+ return assistants.map(textOf).filter(Boolean).join("\n\n").trim();
15559
+ }
15560
+ function capSubTaskText(text, max = SUBTASK_MAX) {
15561
+ if (text.length <= max)
15562
+ return text;
15563
+ const head = sliceSafeEnd(text, Math.floor(max * 0.6));
15564
+ const tail = sliceSafeStart(text, text.length - Math.floor(max * 0.4));
15565
+ return `${head}
15566
+
15567
+ [\u2026 sub-task output truncated (${text.length} chars) \u2014 kept the beginning and end \u2026]
15568
+
15569
+ ${tail}`;
15570
+ }
15571
+ function summariseSubTaskProgress(messages) {
15572
+ const toolNames = [];
15573
+ for (const m2 of messages) {
15574
+ if (m2.role !== "assistant" || !Array.isArray(m2.content))
15575
+ continue;
15576
+ for (const b of m2.content) {
15577
+ if (b?.type === "tool_use" && typeof b.name === "string")
15578
+ toolNames.push(b.name);
15579
+ }
15580
+ }
15581
+ if (toolNames.length === 0)
15582
+ return "";
15583
+ const counts = /* @__PURE__ */ new Map();
15584
+ for (const n of toolNames)
15585
+ counts.set(n, (counts.get(n) ?? 0) + 1);
15586
+ const inventory = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([name, n]) => n > 1 ? `${name} \xD7${n}` : name).join(", ");
15587
+ return `Tool calls completed before it was stopped (${toolNames.length} total): ${inventory}.`;
15588
+ }
14987
15589
  async function runSubTask(input, options, agentTypes) {
14988
15590
  const prompt2 = typeof input.prompt === "string" ? input.prompt.trim() : "";
14989
15591
  if (!prompt2)
14990
15592
  return { error: "task tool requires a non-empty prompt" };
14991
15593
  const depth = options._depth ?? 0;
14992
15594
  if (depth >= MAX_TASK_DEPTH) {
14993
- return { error: `Sub-task depth limit (${MAX_TASK_DEPTH}) reached \u2014 sub-agents cannot spawn further sub-agents.` };
15595
+ return { error: "Sub-agents cannot spawn further sub-agents. Do this work directly, or report back so the main agent can delegate it." };
14994
15596
  }
14995
15597
  const requestedType = typeof input.subagent_type === "string" ? input.subagent_type : "";
14996
15598
  const agent = (0, agentTypes_1.findAgentType)(agentTypes, requestedType);
@@ -15008,6 +15610,8 @@ ${options.nexrallMd}` : "") : options.nexrallMd;
15008
15610
  const gatedPermission = async (req) => {
15009
15611
  if (allowed && !allowed.has(req.tool))
15010
15612
  return false;
15613
+ if (agent?.testFilesOnly && !allowsTestOnlyWrite(req.tool, req.input))
15614
+ return false;
15011
15615
  return options.requestPermission(req);
15012
15616
  };
15013
15617
  const subMessages = [
@@ -15064,22 +15668,36 @@ ${options.nexrallMd}` : "") : options.nexrallMd;
15064
15668
  onThinkingProgress: (tok) => options.onThinkingProgress?.(tok)
15065
15669
  });
15066
15670
  if (subAbort.aborted && !options.abortSignal?.aborted) {
15067
- return { error: `Sub-task stalled and was stopped after ${Math.round(SUBTASK_TIMEOUT_MS / 6e4)} minutes with no completion. Consider breaking it into smaller sub-tasks or investigating what it may have been stuck on (a hung command, an unresponsive MCP/tool call, or a very large scope).` };
15068
- }
15069
- const lastAssistant = [...result].reverse().find((m2) => m2.role === "assistant");
15070
- let text = (lastAssistant?.content ?? []).filter((b) => b.type === "text").map((b) => b.text).join("").trim();
15071
- const SUBTASK_MAX = 48e3;
15072
- if (text.length > SUBTASK_MAX) {
15073
- const head = text.slice(0, Math.floor(SUBTASK_MAX * 0.6));
15074
- const tail = text.slice(text.length - Math.floor(SUBTASK_MAX * 0.4));
15075
- text = `${head}
15076
-
15077
- [\u2026 sub-task output truncated (${text.length} chars) \u2014 kept the beginning and end \u2026]
15078
-
15079
- ${tail}`;
15080
- }
15671
+ const mins = Math.round(SUBTASK_TIMEOUT_MS / 6e4);
15672
+ const partial = capSubTaskText(extractSubTaskText(result, false));
15673
+ const progress = summariseSubTaskProgress(result);
15674
+ const sections = [
15675
+ `Sub-task STOPPED after ${mins} minutes without completing \u2014 treat the following as PARTIAL, unverified work, not a finished answer.`,
15676
+ progress,
15677
+ partial ? `Partial output before it was stopped:
15678
+
15679
+ ${partial}` : "",
15680
+ "Do NOT simply re-run the same sub-task: build on what is above, or split the remaining work into smaller, more focused sub-tasks."
15681
+ ].filter(Boolean);
15682
+ return { error: sections.join("\n\n") };
15683
+ }
15684
+ const text = capSubTaskText(extractSubTaskText(result, true));
15081
15685
  return { output: text || "(sub-task completed with no text output)" };
15082
15686
  } catch (err) {
15687
+ const salvaged = (0, types_1.salvageHistory)(err);
15688
+ if (salvaged) {
15689
+ const partial = capSubTaskText(extractSubTaskText(salvaged, false));
15690
+ const progress = summariseSubTaskProgress(salvaged);
15691
+ const sections = [
15692
+ `Sub-task FAILED before completing: ${err.message}`,
15693
+ progress,
15694
+ partial ? `Partial output before the failure:
15695
+
15696
+ ${partial}` : "",
15697
+ "Treat the above as PARTIAL, unverified work. Build on it rather than re-running the whole sub-task."
15698
+ ].filter(Boolean);
15699
+ return { error: sections.join("\n\n") };
15700
+ }
15083
15701
  return { error: `Sub-task failed: ${err.message}` };
15084
15702
  } finally {
15085
15703
  clearTimeout(timer);
@@ -15138,6 +15756,15 @@ ${tail}`;
15138
15756
  return true;
15139
15757
  }
15140
15758
  exports.WRITE_TOOL_NAMES = /* @__PURE__ */ new Set(["write_file", "edit_file", "multi_edit", "delete_file", "move_file", "copy_file", "notebook_edit"]);
15759
+ function allowsTestOnlyWrite(tool, input) {
15760
+ if (!exports.WRITE_TOOL_NAMES.has(tool))
15761
+ return true;
15762
+ const pathKeys = tool === "notebook_edit" ? ["path"] : ["path", "source", "dest", "destination"];
15763
+ const candidates = pathKeys.map((k) => input?.[k]).filter((v) => typeof v === "string" && v.length > 0);
15764
+ if (candidates.length === 0)
15765
+ return false;
15766
+ return candidates.every((p) => (0, testIntegrity_1.isTestFile)(p));
15767
+ }
15141
15768
  exports.VERIFY_CMD_RE = /\b(npm|yarn|pnpm)\s+(run\s+)?(test|build|lint|typecheck|tsc)\b|\bpytest\b|\bgo\s+(test|vet|build)\b|\btsc\b|\beslint\b|\bcargo\s+(test|build|check)\b/i;
15142
15769
  function findSafeCutIndex(messages, maxIdx) {
15143
15770
  for (let i2 = Math.min(maxIdx, messages.length - 1); i2 >= 2; i2--) {
@@ -15678,7 +16305,7 @@ Continue the work from here.` }] });
15678
16305
  if (!permitted) {
15679
16306
  result = { error: "Permission denied by user" };
15680
16307
  } else if (name === "task") {
15681
- result = await runSubTask(input, options, agentTypes);
16308
+ result = depth === 0 ? await _subTaskLimit(() => runSubTask(input, options, agentTypes)) : await runSubTask(input, options, agentTypes);
15682
16309
  } else {
15683
16310
  const pre = runToolHooks(hooks.PreToolUse, "PreToolUse", name, input, options.workDir);
15684
16311
  if (pre.block) {
@@ -17464,6 +18091,7 @@ var require_dist2 = __commonJS({
17464
18091
  __exportStar(require_loop(), exports);
17465
18092
  __exportStar(require_testIntegrity(), exports);
17466
18093
  __exportStar(require_editCompleteness(), exports);
18094
+ __exportStar(require_securityLint(), exports);
17467
18095
  __exportStar(require_crossFile(), exports);
17468
18096
  __exportStar(require_flaky(), exports);
17469
18097
  __exportStar(require_claimEvidence(), exports);
@@ -51904,6 +52532,7 @@ async function logoutCommand() {
51904
52532
  console.log();
51905
52533
  return;
51906
52534
  }
52535
+ await (0, import_code_core.revokeRefreshToken)();
51907
52536
  (0, import_code_core.clearAuth)();
51908
52537
  console.log(source_default.green(" \u2713 Logged out."));
51909
52538
  console.log();
@@ -62537,7 +63166,7 @@ var InkReadlineAdapter = class extends EventEmitter3 {
62537
63166
  };
62538
63167
 
62539
63168
  // src/commands/chat.ts
62540
- var CLI_VERSION = "0.5.47";
63169
+ var CLI_VERSION = "0.5.49";
62541
63170
  var MODEL_LABELS = {
62542
63171
  turbo: "Nexrall Turbo",
62543
63172
  pro: "Nexrall Pro",
@@ -62622,28 +63251,83 @@ ${content}`);
62622
63251
  }
62623
63252
  return parts.length > 0 ? parts.join("\n\n---\n\n") : void 0;
62624
63253
  }
63254
+ var THINK_TAIL_CHARS = 48;
63255
+ function composeStatusLine(opts) {
63256
+ const timer = source_default.dim(` (${opts.elapsedSec}s)`);
63257
+ const hint = opts.hint !== false && !opts.detail && opts.elapsedSec >= 15 ? source_default.dim(" \xB7 still running, no output yet is normal for quiet commands") : "";
63258
+ const detail = opts.detail ? source_default.dim(` ${opts.detail}`) : "";
63259
+ return `${source_default.cyan(opts.frame)} ${source_default.dim(opts.text)}${timer}${detail}${hint}`;
63260
+ }
63261
+ function formatProgressTokens(tokens) {
63262
+ if (!Number.isFinite(tokens) || tokens <= 0)
63263
+ return "";
63264
+ return tokens >= 1e3 ? `~${(tokens / 1e3).toFixed(1)}k tokens` : `~${tokens} tokens`;
63265
+ }
62625
63266
  var Spinner = class {
62626
63267
  interval = null;
62627
63268
  frames = ["\u25D0", "\u25D3", "\u25D1", "\u25D2"];
62628
63269
  i = 0;
62629
63270
  startedAt = 0;
62630
63271
  baseText = "";
62631
- start(text) {
63272
+ // Extra live detail appended after the elapsed timer (e.g. "~1.2k tokens").
63273
+ // Kept separate from `baseText` and mutated through setDetail() so a stream of
63274
+ // progress events can refresh it WITHOUT restarting the spinner — calling
63275
+ // start() again would reset `startedAt` and the elapsed counter would sit at 0s
63276
+ // forever, which is precisely the "is it frozen?" signal this class exists to
63277
+ // remove.
63278
+ detail = "";
63279
+ // Whether the reassurance hint applies. Off for phases where slowness is
63280
+ // already explained by the detail text (e.g. a live token counter is itself
63281
+ // proof of life, so "no output yet is normal" would be noise).
63282
+ hintEnabled = true;
63283
+ start(text, opts) {
62632
63284
  this.stop();
62633
63285
  this.baseText = text;
63286
+ this.detail = "";
63287
+ this.hintEnabled = opts?.hint !== false;
62634
63288
  this.startedAt = Date.now();
62635
- this.interval = setInterval(() => {
62636
- const elapsedSec = Math.floor((Date.now() - this.startedAt) / 1e3);
62637
- const timer = source_default.dim(` (${elapsedSec}s)`);
62638
- const hint = elapsedSec >= 15 ? source_default.dim(" \xB7 still running, no output yet is normal for quiet commands") : "";
62639
- getInkTerminal()?.setLive(`${source_default.cyan(this.frames[this.i % this.frames.length])} ${source_default.dim(this.baseText)}${timer}${hint}`);
62640
- this.i++;
62641
- }, 100);
63289
+ this.render();
63290
+ this.interval = setInterval(() => this.render(), 100);
63291
+ }
63292
+ /**
63293
+ * Replace the trailing detail text in place, keeping the elapsed timer running.
63294
+ *
63295
+ * No-op when the spinner isn't active: progress events can arrive a tick after
63296
+ * something else (a tool row, the first text delta) legitimately stopped it, and
63297
+ * resurrecting the spinner there would fight the printed output for the status line.
63298
+ */
63299
+ setDetail(detail) {
63300
+ if (this.interval === null)
63301
+ return;
63302
+ if (this.detail === detail)
63303
+ return;
63304
+ this.detail = detail;
63305
+ this.render();
63306
+ }
63307
+ /** Swap the label without resetting the elapsed timer (phase change within one turn). */
63308
+ setText(text) {
63309
+ if (this.interval === null)
63310
+ return;
63311
+ if (this.baseText === text)
63312
+ return;
63313
+ this.baseText = text;
63314
+ this.render();
63315
+ }
63316
+ render() {
63317
+ getInkTerminal()?.setLive(composeStatusLine({
63318
+ frame: this.frames[this.i % this.frames.length],
63319
+ text: this.baseText,
63320
+ elapsedSec: Math.floor((Date.now() - this.startedAt) / 1e3),
63321
+ detail: this.detail,
63322
+ hint: this.hintEnabled
63323
+ }));
63324
+ this.i++;
62642
63325
  }
62643
63326
  stop() {
62644
63327
  if (this.interval !== null) {
62645
63328
  clearInterval(this.interval);
62646
63329
  this.interval = null;
63330
+ this.detail = "";
62647
63331
  getInkTerminal()?.setLive("");
62648
63332
  }
62649
63333
  }
@@ -62719,8 +63403,26 @@ async function runTurn(messages, modelAlias, workDir, abortSignal, env3, nexrall
62719
63403
  let toolStartTime = 0;
62720
63404
  let lastToolName = "";
62721
63405
  let thinkingTokens = 0;
63406
+ let liveThinkTail = "";
63407
+ const pushStatus = () => {
63408
+ const parts = [];
63409
+ const tok = formatProgressTokens(thinkingTokens);
63410
+ if (tok)
63411
+ parts.push(tok);
63412
+ if (liveThinkTail)
63413
+ parts.push(liveThinkTail);
63414
+ spinner.setDetail(parts.join(" \xB7 "));
63415
+ };
63416
+ const resumeWorking = (label = "working\u2026") => {
63417
+ if (abortSignal.aborted)
63418
+ return;
63419
+ thinkingTokens = 0;
63420
+ liveThinkTail = "";
63421
+ spinner.start(label, { hint: false });
63422
+ };
62722
63423
  setMode(mode);
62723
63424
  console.log();
63425
+ spinner.start("thinking\u2026", { hint: false });
62724
63426
  const result = await (0, import_code_core3.runAgentLoop)(messages, {
62725
63427
  workDir,
62726
63428
  model: modelAlias,
@@ -62728,10 +63430,26 @@ async function runTurn(messages, modelAlias, workDir, abortSignal, env3, nexrall
62728
63430
  nexrallMd,
62729
63431
  mode,
62730
63432
  effort,
63433
+ // The backend streams a cumulative output-token count (routes/code.js's
63434
+ // sendProgress, throttled to ≤5/s) covering thinking, visible text AND
63435
+ // tool-argument JSON. This used to be stored in a variable and rendered only
63436
+ // at message_complete — i.e. after the turn was already over — so during the
63437
+ // long phase it exists to describe, it showed nothing at all.
62731
63438
  onThinkingProgress: (tokens) => {
62732
63439
  thinkingTokens = tokens;
63440
+ pushStatus();
62733
63441
  },
62734
- onThinkingDelta: () => {
63442
+ // Live thinking text. Previously a hard no-op with the note "shown on
63443
+ // onThinking" — but onThinking only fires at message_complete, so a long
63444
+ // reasoning phase rendered nothing whatsoever until it had finished. Show a
63445
+ // short rolling tail on the status line so the user can see it actively
63446
+ // reasoning, then let onThinking print the proper summary block at the end.
63447
+ onThinkingDelta: (text) => {
63448
+ if (abortSignal.aborted)
63449
+ return;
63450
+ const flat = text.replace(/\s+/g, " ");
63451
+ liveThinkTail = (liveThinkTail + flat).slice(-THINK_TAIL_CHARS);
63452
+ pushStatus();
62735
63453
  },
62736
63454
  onThinking: (text) => {
62737
63455
  if (abortSignal.aborted)
@@ -62739,6 +63457,8 @@ async function runTurn(messages, modelAlias, workDir, abortSignal, env3, nexrall
62739
63457
  spinner.stop();
62740
63458
  console.log(formatThinking(text, thinkingTokens));
62741
63459
  thinkingTokens = 0;
63460
+ liveThinkTail = "";
63461
+ resumeWorking();
62742
63462
  },
62743
63463
  onText: (text) => {
62744
63464
  if (abortSignal.aborted)
@@ -62757,6 +63477,7 @@ async function runTurn(messages, modelAlias, workDir, abortSignal, env3, nexrall
62757
63477
  mdRender.flush();
62758
63478
  spinner.stop();
62759
63479
  console.log(source_default.dim(` ${text}`));
63480
+ resumeWorking();
62760
63481
  },
62761
63482
  onToolUse: (name, input) => {
62762
63483
  if (abortSignal.aborted)
@@ -62787,6 +63508,7 @@ async function runTurn(messages, modelAlias, workDir, abortSignal, env3, nexrall
62787
63508
  const durationMs = Date.now() - toolStartTime;
62788
63509
  console.log(formatToolResult(lastToolName, res, durationMs));
62789
63510
  toolStartTime = 0;
63511
+ resumeWorking();
62790
63512
  },
62791
63513
  // Ignore a `partial` report: it belongs to a cut-short attempt that was restarted,
62792
63514
  // and the replacement attempt reports the turn's real totals. Letting it through
@@ -63822,6 +64544,12 @@ async function confirmInstall(i2, name, autoYes) {
63822
64544
  if (autoYes && dangerous) {
63823
64545
  console.log(source_default.yellow("\n --yes does not apply to plugins with hooks/MCP; confirmation required."));
63824
64546
  }
64547
+ if (!process.stdin.isTTY) {
64548
+ console.log();
64549
+ console.log(source_default.red(" \u2717 Cannot ask for confirmation: stdin is not a terminal."));
64550
+ console.log(source_default.dim(dangerous ? " This plugin ships hooks/MCP, which always require an interactive confirmation." : " Re-run with --yes to install non-interactively."));
64551
+ return false;
64552
+ }
63825
64553
  console.log();
63826
64554
  const { ok } = await (0, import_prompts.default)({
63827
64555
  type: "confirm",
@@ -63989,7 +64717,7 @@ function pluginListCommand() {
63989
64717
 
63990
64718
  // src/index.ts
63991
64719
  var program2 = new Command();
63992
- program2.name("nex").description("Nexrall Code \u2014 AI coding assistant (powered by Nexrall)").version("0.5.47");
64720
+ program2.name("nex").description("Nexrall Code \u2014 AI coding assistant (powered by Nexrall)").version("0.5.49").enablePositionalOptions();
63993
64721
  program2.command("auth").description("Login to your Nexrall account").action(authCommand);
63994
64722
  program2.command("logout").description("Log out of your Nexrall account").action(logoutCommand);
63995
64723
  program2.command("update").description("Update nex to the latest version").option("-c, --check", "Check for updates without installing").option("-y, --yes", "Skip confirmation prompt").action(async (opts) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexrall-code",
3
- "version": "0.5.47",
3
+ "version": "0.5.49",
4
4
  "description": "Nexrall Code — AI coding assistant for your terminal (headless agent for scripts, CI and automation)",
5
5
  "keywords": [
6
6
  "ai",
@@ -41,7 +41,7 @@
41
41
  "react": "^19.2.8",
42
42
  "readline": "^1.3.0",
43
43
  "string-width": "^7.2.0",
44
- "@nexrall/code-core": "1.4.22"
44
+ "@nexrall/code-core": "1.4.24"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@aws-sdk/client-s3": "^3.600.0",