nexrall-code 0.5.75 → 0.5.78

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/dist/index.js +283 -59
  2. package/nex-watch.js +117 -0
  3. package/package.json +6 -4
package/dist/index.js CHANGED
@@ -14029,7 +14029,7 @@ ${diff3}${xfile}${sec}` };
14029
14029
  }
14030
14030
  return null;
14031
14031
  }
14032
- async function fetchUrl(input, _workDir2, _redirectCount = 0) {
14032
+ async function fetchUrl(input, _workDir2, _redirectCount = 0, abortSignal) {
14033
14033
  const url = typeof input.url === "string" ? input.url : "";
14034
14034
  if (!url)
14035
14035
  return { error: "Missing required parameter: url" };
@@ -14073,8 +14073,20 @@ ${diff3}${xfile}${sec}` };
14073
14073
  if (settled)
14074
14074
  return;
14075
14075
  settled = true;
14076
+ if (abortPoll !== void 0)
14077
+ clearInterval(abortPoll);
14076
14078
  resolve3(result);
14077
14079
  };
14080
+ let abortPoll;
14081
+ if (abortSignal) {
14082
+ abortPoll = setInterval(() => {
14083
+ if (abortSignal.aborted && !settled) {
14084
+ selfAborted = true;
14085
+ req.destroy();
14086
+ finish({ error: "Request stopped by user", interrupted: true });
14087
+ }
14088
+ }, 100);
14089
+ }
14078
14090
  const req = transport.get(url, {
14079
14091
  timeout: DEFAULT_TIMEOUT_MS,
14080
14092
  lookup: guardedLookup,
@@ -14096,7 +14108,7 @@ ${diff3}${xfile}${sec}` };
14096
14108
  finish({ error: `Invalid redirect location: ${res.headers.location}` });
14097
14109
  return;
14098
14110
  }
14099
- fetchUrl({ url: nextUrl }, void 0, _redirectCount + 1).then(finish);
14111
+ fetchUrl({ url: nextUrl }, void 0, _redirectCount + 1, abortSignal).then(finish);
14100
14112
  return;
14101
14113
  }
14102
14114
  const contentType = res.headers["content-type"] ?? "";
@@ -14434,45 +14446,83 @@ ${lines.join("\n")}`;
14434
14446
  }
14435
14447
  }
14436
14448
  var VALID_ASPECT_RATIOS = /* @__PURE__ */ new Set(["1:1", "9:16", "16:9", "3:4", "4:3", "3:2", "2:3", "5:4", "4:5", "21:9", "auto"]);
14437
- function _httpsPost(url, body, headers, timeoutMs = 3e4) {
14449
+ function _httpsPost(url, body, headers, timeoutMs = 3e4, abortSignal) {
14438
14450
  return new Promise((resolve3, reject) => {
14439
14451
  const u = new URL(url);
14440
14452
  const chunks = [];
14453
+ let settled = false;
14454
+ let abortPoll;
14455
+ const done = (fn) => {
14456
+ if (settled)
14457
+ return;
14458
+ settled = true;
14459
+ if (abortPoll)
14460
+ clearInterval(abortPoll);
14461
+ fn();
14462
+ };
14441
14463
  const req = https3.request({ hostname: u.hostname, path: u.pathname + u.search, method: "POST", headers: { ...headers, "Content-Length": Buffer.byteLength(body) }, timeout: timeoutMs }, (res) => {
14442
14464
  res.on("data", (c) => chunks.push(c));
14443
- res.on("end", () => resolve3({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString("utf-8") }));
14465
+ res.on("end", () => done(() => resolve3({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString("utf-8") })));
14444
14466
  });
14445
14467
  req.on("timeout", () => {
14446
14468
  req.destroy();
14447
- reject(new Error(`Request timed out after ${timeoutMs}ms`));
14469
+ done(() => reject(new Error(`Request timed out after ${timeoutMs}ms`)));
14448
14470
  });
14449
- req.on("error", reject);
14471
+ req.on("error", (err) => done(() => reject(err)));
14472
+ if (abortSignal) {
14473
+ abortPoll = setInterval(() => {
14474
+ if (abortSignal.aborted && !settled) {
14475
+ req.destroy();
14476
+ done(() => reject(Object.assign(new Error("Stopped by user"), { name: "AbortError" })));
14477
+ }
14478
+ }, 100);
14479
+ }
14450
14480
  req.write(body);
14451
14481
  req.end();
14452
14482
  });
14453
14483
  }
14454
- function _httpsGet(url, headers, timeoutMs = 2e4) {
14484
+ function _httpsGet(url, headers, timeoutMs = 2e4, abortSignal) {
14455
14485
  return new Promise((resolve3, reject) => {
14456
14486
  const u = new URL(url);
14457
14487
  const transport = u.protocol === "https:" ? https3 : http3;
14458
14488
  const chunks = [];
14489
+ let settled = false;
14490
+ let abortPoll;
14491
+ const done = (fn) => {
14492
+ if (settled)
14493
+ return;
14494
+ settled = true;
14495
+ if (abortPoll)
14496
+ clearInterval(abortPoll);
14497
+ fn();
14498
+ };
14459
14499
  const req = transport.get({ hostname: u.hostname, path: u.pathname + u.search, headers, timeout: timeoutMs }, (res) => {
14460
14500
  if ((res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) && res.headers.location) {
14461
14501
  req.destroy();
14462
- _httpsGet(new URL(res.headers.location, url).href, headers, timeoutMs).then(resolve3, reject);
14502
+ done(() => {
14503
+ _httpsGet(new URL(res.headers.location, url).href, headers, timeoutMs, abortSignal).then(resolve3, reject);
14504
+ });
14463
14505
  return;
14464
14506
  }
14465
14507
  res.on("data", (c) => chunks.push(c));
14466
- res.on("end", () => resolve3({ status: res.statusCode ?? 0, body: Buffer.concat(chunks), contentType: res.headers["content-type"] ?? "" }));
14508
+ res.on("end", () => done(() => resolve3({ status: res.statusCode ?? 0, body: Buffer.concat(chunks), contentType: res.headers["content-type"] ?? "" })));
14467
14509
  });
14468
14510
  req.on("timeout", () => {
14469
14511
  req.destroy();
14470
- reject(new Error(`Download timed out after ${timeoutMs}ms`));
14512
+ done(() => reject(new Error(`Download timed out after ${timeoutMs}ms`)));
14471
14513
  });
14472
- req.on("error", reject);
14514
+ req.on("error", (err) => done(() => reject(err)));
14515
+ if (abortSignal) {
14516
+ abortPoll = setInterval(() => {
14517
+ if (abortSignal.aborted && !settled) {
14518
+ req.destroy();
14519
+ done(() => reject(Object.assign(new Error("Stopped by user"), { name: "AbortError" })));
14520
+ }
14521
+ }, 100);
14522
+ }
14473
14523
  });
14474
14524
  }
14475
- async function generateImage(input, workDir) {
14525
+ async function generateImage(input, workDir, abortSignal) {
14476
14526
  const prompt2 = typeof input.prompt === "string" ? input.prompt.trim() : "";
14477
14527
  const relPath = typeof input.path === "string" ? input.path.trim() : "";
14478
14528
  const aspectRatio = typeof input.aspect_ratio === "string" && VALID_ASPECT_RATIOS.has(input.aspect_ratio) ? input.aspect_ratio : "1:1";
@@ -14487,7 +14537,7 @@ ${lines.join("\n")}`;
14487
14537
  let cost;
14488
14538
  let balance;
14489
14539
  try {
14490
- const r2 = await _httpsPost(`${client_1.API_BASE}/api/code/assets/generate`, JSON.stringify({ prompt: prompt2, aspect_ratio: aspectRatio }), { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, 17e4);
14540
+ const r2 = await _httpsPost(`${client_1.API_BASE}/api/code/assets/generate`, JSON.stringify({ prompt: prompt2, aspect_ratio: aspectRatio }), { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, 17e4, abortSignal);
14491
14541
  let json = {};
14492
14542
  try {
14493
14543
  json = JSON.parse(r2.body);
@@ -14501,6 +14551,8 @@ ${lines.join("\n")}`;
14501
14551
  if (!imageUrl)
14502
14552
  return { error: "Image generation succeeded but no URL was returned." };
14503
14553
  } catch (err) {
14554
+ if (err.name === "AbortError")
14555
+ return { error: "generate_image stopped by user", interrupted: true };
14504
14556
  return { error: `generate_image failed: ${err.message}` };
14505
14557
  }
14506
14558
  const destPath = resolvePath(relPath, workDir);
@@ -14509,7 +14561,7 @@ ${lines.join("\n")}`;
14509
14561
  } catch (_) {
14510
14562
  }
14511
14563
  try {
14512
- const dl = await _httpsGet(imageUrl, { "User-Agent": FETCH_USER_AGENT }, 6e4);
14564
+ const dl = await _httpsGet(imageUrl, { "User-Agent": FETCH_USER_AGENT }, 6e4, abortSignal);
14513
14565
  if (dl.status !== 200)
14514
14566
  return { error: `Failed to download generated image (HTTP ${dl.status}).` };
14515
14567
  fs9.writeFileSync(destPath, dl.body);
@@ -14517,10 +14569,12 @@ ${lines.join("\n")}`;
14517
14569
  const costNote = cost != null ? ` ($${cost.toFixed(2)} charged` + (balance != null ? `, balance $${balance.toFixed(2)})` : ")") : "";
14518
14570
  return { output: `Generated and saved image to ${destPath} (${kb} KB)${costNote}.` };
14519
14571
  } catch (err) {
14572
+ if (err.name === "AbortError")
14573
+ return { error: "generate_image stopped by user (image was generated and billed, but the download was interrupted)", interrupted: true };
14520
14574
  return { error: `Failed to save image to ${destPath}: ${err.message}` };
14521
14575
  }
14522
14576
  }
14523
- async function stockPhoto(input, workDir) {
14577
+ async function stockPhoto(input, workDir, abortSignal) {
14524
14578
  const query = typeof input.query === "string" ? input.query.trim() : "";
14525
14579
  const relPath = typeof input.path === "string" ? input.path.trim() : "";
14526
14580
  const count = typeof input.count === "number" ? Math.min(Math.max(Math.floor(input.count), 1), 10) : 3;
@@ -14537,7 +14591,7 @@ ${lines.join("\n")}`;
14537
14591
  u.searchParams.set("count", String(count));
14538
14592
  if (orientation)
14539
14593
  u.searchParams.set("orientation", orientation);
14540
- const r2 = await _httpsGet(u.toString(), { Authorization: `Bearer ${token}` });
14594
+ const r2 = await _httpsGet(u.toString(), { Authorization: `Bearer ${token}` }, 2e4, abortSignal);
14541
14595
  let json = {};
14542
14596
  try {
14543
14597
  json = JSON.parse(r2.body.toString("utf-8"));
@@ -14549,6 +14603,8 @@ ${lines.join("\n")}`;
14549
14603
  if (!photos.length)
14550
14604
  return { output: `No stock photos found for "${query}".` };
14551
14605
  } catch (err) {
14606
+ if (err.name === "AbortError")
14607
+ return { error: "stock_photo stopped by user", interrupted: true };
14552
14608
  return { error: `stock_photo search failed: ${err.message}` };
14553
14609
  }
14554
14610
  if (relPath) {
@@ -14561,7 +14617,7 @@ ${lines.join("\n")}`;
14561
14617
  } catch (_) {
14562
14618
  }
14563
14619
  try {
14564
- const dl = await _httpsGet(top.url, { "User-Agent": FETCH_USER_AGENT });
14620
+ const dl = await _httpsGet(top.url, { "User-Agent": FETCH_USER_AGENT }, 2e4, abortSignal);
14565
14621
  if (dl.status !== 200)
14566
14622
  return { error: `Failed to download photo (HTTP ${dl.status}).` };
14567
14623
  fs9.writeFileSync(destPath, dl.body);
@@ -14569,6 +14625,8 @@ ${lines.join("\n")}`;
14569
14625
  const credit = top.credit ? ` \u2014 Photo by ${top.credit} on Unsplash` : "";
14570
14626
  return { output: `Downloaded "${top.alt || query}" to ${destPath} (${kb} KB).${credit}` };
14571
14627
  } catch (err) {
14628
+ if (err.name === "AbortError")
14629
+ return { error: "stock_photo download stopped by user", interrupted: true };
14572
14630
  return { error: `Failed to save photo to ${destPath}: ${err.message}` };
14573
14631
  }
14574
14632
  }
@@ -14576,7 +14634,7 @@ ${lines.join("\n")}`;
14576
14634
  return { output: `Found ${photos.length} photo(s) for "${query}":
14577
14635
  ${lines.join("\n")}` };
14578
14636
  }
14579
- async function webSearch(input) {
14637
+ async function webSearch(input, _workDir2, abortSignal) {
14580
14638
  const query = typeof input.query === "string" ? input.query.trim() : "";
14581
14639
  if (!query)
14582
14640
  return { error: "Missing required parameter: query" };
@@ -14584,7 +14642,7 @@ ${lines.join("\n")}` };
14584
14642
  if (!token)
14585
14643
  return { error: "Not authenticated. Run `nexrall-code login` first." };
14586
14644
  try {
14587
- const r2 = await _httpsPost(`${client_1.API_BASE}/api/code/tools/web_search`, JSON.stringify({ query }), { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, 3e4);
14645
+ const r2 = await _httpsPost(`${client_1.API_BASE}/api/code/tools/web_search`, JSON.stringify({ query }), { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, 3e4, abortSignal);
14588
14646
  let json = {};
14589
14647
  try {
14590
14648
  json = JSON.parse(r2.body);
@@ -14597,6 +14655,8 @@ ${lines.join("\n")}` };
14597
14655
  return { error: "web_search succeeded but returned no results text." };
14598
14656
  return { output: text };
14599
14657
  } catch (err) {
14658
+ if (err.name === "AbortError")
14659
+ return { error: "web_search stopped by user", interrupted: true };
14600
14660
  return { error: `web_search failed: ${err.message}` };
14601
14661
  }
14602
14662
  }
@@ -14731,8 +14791,6 @@ ${expanded}` };
14731
14791
  "delete_file",
14732
14792
  "notebook_read",
14733
14793
  "notebook_edit",
14734
- "generate_image",
14735
- "stock_photo",
14736
14794
  "get_symbols",
14737
14795
  "get_workspace_symbols",
14738
14796
  "go_to_definition",
@@ -14752,6 +14810,14 @@ ${expanded}` };
14752
14810
  return await todoWrite(input, agentScope);
14753
14811
  if (name === "todo_read")
14754
14812
  return await todoRead(input, agentScope);
14813
+ if (name === "fetch_url")
14814
+ return await fetchUrl(input, workDir, 0, abortSignal);
14815
+ if (name === "web_search")
14816
+ return await webSearch(input, workDir, abortSignal);
14817
+ if (name === "generate_image")
14818
+ return await generateImage(input, workDir, abortSignal);
14819
+ if (name === "stock_photo")
14820
+ return await stockPhoto(input, workDir, abortSignal);
14755
14821
  if (WORKDIR_TOOLS.has(name)) {
14756
14822
  return await TOOL_MAP[name](input, workDir);
14757
14823
  }
@@ -17791,7 +17857,7 @@ ${options.nexrallMd}` : "") : options.nexrallMd;
17791
17857
  if (external !== null && external !== void 0) {
17792
17858
  result = external;
17793
17859
  } else if (options.mcpManager?.isMcpTool(name)) {
17794
- const mcpOutput = await options.mcpManager.callTool(name, input);
17860
+ const mcpOutput = await options.mcpManager.callTool(name, input, options.abortSignal);
17795
17861
  result = { output: mcpOutput ?? "" };
17796
17862
  } else {
17797
17863
  options.checkpointManager?.recordBeforeMutation(name, input);
@@ -18555,8 +18621,24 @@ var require_manager = __commonJS({
18555
18621
  }
18556
18622
  return tools;
18557
18623
  }
18558
- /** Execute an MCP tool call. Returns null if this tool is not an MCP tool. */
18559
- async callTool(prefixedName, input) {
18624
+ /**
18625
+ * Execute an MCP tool call. Returns null if this tool is not an MCP tool.
18626
+ *
18627
+ * `abortSignal` is optional and best-effort: the underlying transports (stdio
18628
+ * JSON-RPC / HTTP / SSE) have no cancellation message in this client, so an
18629
+ * abort here does NOT stop the server from finishing its work — it only stops
18630
+ * THIS CALL from making the caller wait for it. Before this was added, Ctrl+C
18631
+ * during an MCP tool call was silently swallowed: loop.ts set
18632
+ * `abortSignal.aborted = true` but nothing downstream of `options.mcpManager
18633
+ * .callTool(name, input)` ever looked at it, so the user's only way out was
18634
+ * to wait out the full 60s MCP_TOOL_TIMEOUT_MS (or however long the server
18635
+ * actually took). Racing the same abortSignal the 60s timeout already races
18636
+ * against makes Ctrl+C return control immediately instead of up to a minute
18637
+ * later — matching how the `bash` tool already behaves (its own 200ms abort
18638
+ * poll in executor.ts), just without the ability to also kill a remote
18639
+ * server's in-flight work the way `bash` can kill a local child process.
18640
+ */
18641
+ async callTool(prefixedName, input, abortSignal) {
18560
18642
  const serverName = this._toolMap.get(prefixedName);
18561
18643
  if (!serverName)
18562
18644
  return null;
@@ -18566,7 +18648,21 @@ var require_manager = __commonJS({
18566
18648
  const toolName = prefixedName.slice(serverName.length + 2);
18567
18649
  const MCP_TOOL_TIMEOUT_MS = 6e4;
18568
18650
  const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`MCP tool "${prefixedName}" timed out after ${MCP_TOOL_TIMEOUT_MS / 1e3}s`)), MCP_TOOL_TIMEOUT_MS));
18569
- return Promise.race([client.callTool(toolName, input), timeoutPromise]);
18651
+ const racers = [client.callTool(toolName, input), timeoutPromise];
18652
+ if (abortSignal) {
18653
+ let pollId;
18654
+ const abortPromise = new Promise((_, reject) => {
18655
+ pollId = setInterval(() => {
18656
+ if (abortSignal.aborted) {
18657
+ clearInterval(pollId);
18658
+ reject(Object.assign(new Error("Aborted"), { name: "AbortError" }));
18659
+ }
18660
+ }, 100);
18661
+ });
18662
+ racers.push(abortPromise);
18663
+ return Promise.race(racers).finally(() => clearInterval(pollId));
18664
+ }
18665
+ return Promise.race(racers);
18570
18666
  }
18571
18667
  /** True if this tool name belongs to an MCP server. */
18572
18668
  isMcpTool(name) {
@@ -55721,7 +55817,18 @@ function prepareSessionScreen() {
55721
55817
  return;
55722
55818
  process.stdout.write("\x1B[2J\x1B[3J\x1B[H");
55723
55819
  }
55724
- var BOTTOM_CHROME_ROWS = 3;
55820
+ function bottomChromeRows(opts = {
55821
+ footerText: ""
55822
+ }) {
55823
+ const columns = opts.columns ?? process.stdout.columns ?? 80;
55824
+ const separatorRows = 1;
55825
+ const inputRows = rowsOccupied(
55826
+ " ".repeat(opts.promptWidth ?? 2) + (opts.inputText ?? ""),
55827
+ columns
55828
+ );
55829
+ const footerRows = rowsOccupied(opts.footerText, columns);
55830
+ return separatorRows + inputRows + footerRows;
55831
+ }
55725
55832
  function rowsOccupied(text, columns = process.stdout.columns || 80) {
55726
55833
  let rows = 0;
55727
55834
  for (const line of text.split("\n")) {
@@ -55730,11 +55837,12 @@ function rowsOccupied(text, columns = process.stdout.columns || 80) {
55730
55837
  }
55731
55838
  return rows;
55732
55839
  }
55733
- function padToBottom(rowsAlreadyPrinted) {
55840
+ function padToBottom(rowsAlreadyPrinted, footerText2 = "") {
55734
55841
  const rows = process.stdout.rows;
55735
55842
  if (!process.stdout.isTTY || !rows)
55736
55843
  return;
55737
- const filler = rows - rowsAlreadyPrinted - BOTTOM_CHROME_ROWS - 2;
55844
+ const chromeRows = bottomChromeRows({ footerText: footerText2, columns: process.stdout.columns ?? 80 });
55845
+ const filler = rows - rowsAlreadyPrinted - chromeRows - 2;
55738
55846
  const MIN_WORTHWHILE_FILLER = 3;
55739
55847
  if (filler < MIN_WORTHWHILE_FILLER)
55740
55848
  return;
@@ -55745,19 +55853,26 @@ function startRowCount() {
55745
55853
  if (rowCounter)
55746
55854
  return;
55747
55855
  const columns = process.stdout.columns || 80;
55748
- const original = console.log;
55749
- const state = { rows: 0, original };
55856
+ const originalLog = console.log;
55857
+ const originalError = console.error;
55858
+ const state = { rows: 0, originalLog, originalError };
55750
55859
  rowCounter = state;
55860
+ const measure = (args) => args.length === 0 ? 1 : rowsOccupied(args.map(String).join(" "), columns);
55751
55861
  console.log = (...args) => {
55752
- state.rows += args.length === 0 ? 1 : rowsOccupied(args.map(String).join(" "), columns);
55753
- original(...args);
55862
+ state.rows += measure(args);
55863
+ originalLog(...args);
55864
+ };
55865
+ console.error = (...args) => {
55866
+ state.rows += measure(args);
55867
+ originalError(...args);
55754
55868
  };
55755
55869
  }
55756
55870
  function stopRowCount() {
55757
55871
  if (!rowCounter)
55758
55872
  return 0;
55759
- const { rows, original } = rowCounter;
55760
- console.log = original;
55873
+ const { rows, originalLog, originalError } = rowCounter;
55874
+ console.log = originalLog;
55875
+ console.error = originalError;
55761
55876
  rowCounter = null;
55762
55877
  return rows;
55763
55878
  }
@@ -64777,6 +64892,26 @@ function nextGraphemeBoundary(text, pos) {
64777
64892
  }
64778
64893
  return text.length;
64779
64894
  }
64895
+ function renderInputSlices(line, cursorPos) {
64896
+ const end = nextGraphemeBoundary(line, cursorPos);
64897
+ return {
64898
+ before: line.slice(0, cursorPos),
64899
+ atCursor: line.slice(cursorPos, end) || " ",
64900
+ after: line.slice(end)
64901
+ };
64902
+ }
64903
+ var CTRL_C_QUIT_WINDOW_MS = 3e3;
64904
+ var CTRL_C_HINT = "Press Ctrl+C again to exit";
64905
+ function decideCtrlC(state) {
64906
+ if (state.hasPendingQuestion)
64907
+ return "answer-prompt";
64908
+ if (state.busy)
64909
+ return "interrupt-turn";
64910
+ if (state.quitArmedAt !== null && state.now - state.quitArmedAt <= CTRL_C_QUIT_WINDOW_MS) {
64911
+ return "quit";
64912
+ }
64913
+ return "arm-quit";
64914
+ }
64780
64915
  var handle = null;
64781
64916
  var inkInstance = null;
64782
64917
  var idCounter = 0;
@@ -64806,6 +64941,12 @@ var App2 = ({ onReady }) => {
64806
64941
  const onLineRef = (0, import_react35.useRef)(null);
64807
64942
  const onQueuedLineRef = (0, import_react35.useRef)(null);
64808
64943
  const busyRef = (0, import_react35.useRef)(false);
64944
+ const [quitArmed, setQuitArmedState] = (0, import_react35.useState)(null);
64945
+ const quitArmedAtRef = (0, import_react35.useRef)(null);
64946
+ const setQuitArmed = (0, import_react35.useCallback)((at) => {
64947
+ quitArmedAtRef.current = at;
64948
+ setQuitArmedState(at);
64949
+ }, []);
64809
64950
  const [live, setLiveState] = (0, import_react35.useState)("");
64810
64951
  const print = (0, import_react35.useCallback)((text) => {
64811
64952
  setItems((prev) => [...prev, { id: idCounter++, content: text }]);
@@ -64837,23 +64978,49 @@ var App2 = ({ onReady }) => {
64837
64978
  const onInterrupt = (0, import_react35.useCallback)((cb) => {
64838
64979
  onInterruptRef.current = cb;
64839
64980
  }, []);
64981
+ const onExitRef = (0, import_react35.useRef)(null);
64982
+ const onExit = (0, import_react35.useCallback)((cb) => {
64983
+ onExitRef.current = cb;
64984
+ }, []);
64985
+ const exitedRef = (0, import_react35.useRef)(false);
64986
+ const requestExit = (0, import_react35.useCallback)((notify) => {
64987
+ if (exitedRef.current)
64988
+ return;
64989
+ exitedRef.current = true;
64990
+ if (notify)
64991
+ onExitRef.current?.();
64992
+ exit();
64993
+ }, []);
64840
64994
  use_input_default((char, key) => {
64841
64995
  if (key.ctrl && char === "c") {
64842
- const askResolve = askResolverRef.current;
64843
- if (askResolve) {
64996
+ const action = decideCtrlC({
64997
+ hasPendingQuestion: askResolverRef.current !== null,
64998
+ busy: busyRef.current && onInterruptRef.current !== null,
64999
+ quitArmedAt: quitArmedAtRef.current,
65000
+ now: Date.now()
65001
+ });
65002
+ if (action === "answer-prompt") {
65003
+ const askResolve = askResolverRef.current;
64844
65004
  askResolverRef.current = null;
64845
65005
  setLine("");
64846
65006
  setCursorPos(0);
64847
65007
  askResolve("n");
64848
65008
  return;
64849
65009
  }
64850
- if (busyRef.current && onInterruptRef.current) {
64851
- onInterruptRef.current();
65010
+ if (action === "interrupt-turn") {
65011
+ setQuitArmed(null);
65012
+ onInterruptRef.current?.();
65013
+ return;
65014
+ }
65015
+ if (action === "arm-quit") {
65016
+ setQuitArmed(Date.now());
64852
65017
  return;
64853
65018
  }
64854
- exit();
65019
+ requestExit(true);
64855
65020
  return;
64856
65021
  }
65022
+ if (quitArmedAtRef.current !== null)
65023
+ setQuitArmed(null);
64857
65024
  if (key.backspace || key.delete) {
64858
65025
  if (key.delete) {
64859
65026
  setLine((s2) => {
@@ -64886,6 +65053,12 @@ var App2 = ({ onReady }) => {
64886
65053
  }
64887
65054
  const newlineIdx = char.search(/[\r\n]/);
64888
65055
  const hasNewline = key.return || newlineIdx !== -1;
65056
+ if (hasNewline && key.shift) {
65057
+ const pos = cursorRef.current;
65058
+ setLine((s2) => s2.slice(0, pos) + "\n" + s2.slice(pos));
65059
+ setCursorPos(pos + 1);
65060
+ return;
65061
+ }
64889
65062
  if (hasNewline) {
64890
65063
  const before = newlineIdx === -1 ? char : char.slice(0, newlineIdx);
64891
65064
  const after = newlineIdx === -1 ? "" : char.slice(newlineIdx + 1).replace(/^[\r\n]/, "");
@@ -64920,11 +65093,13 @@ var App2 = ({ onReady }) => {
64920
65093
  onLine,
64921
65094
  onQueuedLine,
64922
65095
  onInterrupt,
65096
+ onExit,
64923
65097
  setBusy,
64924
- close: () => exit()
65098
+ close: () => requestExit(false)
64925
65099
  });
64926
65100
  }, []);
64927
- return /* @__PURE__ */ import_react35.default.createElement(Box_default, { flexDirection: "column" }, /* @__PURE__ */ import_react35.default.createElement(Static, { items }, (item) => /* @__PURE__ */ import_react35.default.createElement(Text, { key: item.id }, item.content)), live ? /* @__PURE__ */ import_react35.default.createElement(Text, null, live) : null, /* @__PURE__ */ import_react35.default.createElement(Text, { dimColor: true }, "\u2500".repeat(Math.max(1, columns - 1))), /* @__PURE__ */ import_react35.default.createElement(Box_default, null, /* @__PURE__ */ import_react35.default.createElement(Text, null, prompt2), /* @__PURE__ */ import_react35.default.createElement(Text, null, line.slice(0, cursorPos)), /* @__PURE__ */ import_react35.default.createElement(Text, { inverse: true }, line.slice(cursorPos, cursorPos + 1) || " "), /* @__PURE__ */ import_react35.default.createElement(Text, null, line.slice(cursorPos + 1))), /* @__PURE__ */ import_react35.default.createElement(Text, { dimColor: true }, footerText(footer), busy ? " \xB7 agent working\u2026" : ""));
65101
+ const slices = renderInputSlices(line, cursorPos);
65102
+ return /* @__PURE__ */ import_react35.default.createElement(Box_default, { flexDirection: "column" }, /* @__PURE__ */ import_react35.default.createElement(Static, { items }, (item) => /* @__PURE__ */ import_react35.default.createElement(Text, { key: item.id }, item.content)), live ? /* @__PURE__ */ import_react35.default.createElement(Text, null, live) : null, /* @__PURE__ */ import_react35.default.createElement(Text, { dimColor: true }, "\u2500".repeat(Math.max(1, columns - 1))), /* @__PURE__ */ import_react35.default.createElement(Box_default, null, /* @__PURE__ */ import_react35.default.createElement(Text, null, prompt2), /* @__PURE__ */ import_react35.default.createElement(Text, null, slices.before), /* @__PURE__ */ import_react35.default.createElement(Text, { inverse: true }, slices.atCursor), /* @__PURE__ */ import_react35.default.createElement(Text, null, slices.after)), quitArmed !== null ? /* @__PURE__ */ import_react35.default.createElement(Text, { color: "yellow" }, CTRL_C_HINT) : /* @__PURE__ */ import_react35.default.createElement(Text, { dimColor: true }, footerText(footer), busy ? " \xB7 agent working\u2026" : ""));
64928
65103
  };
64929
65104
  function startInkTerminal() {
64930
65105
  if (handle)
@@ -64940,7 +65115,36 @@ function startInkTerminal() {
64940
65115
  }
64941
65116
  }
64942
65117
  ),
64943
- { exitOnCtrlC: false }
65118
+ {
65119
+ exitOnCtrlC: false,
65120
+ // Opt into the Kitty keyboard protocol so `key.shift` is actually
65121
+ // populated for Enter (see the Shift+Enter handling in useInput
65122
+ // above) instead of always being false/undefined.
65123
+ //
65124
+ // MUST be 'enabled', not 'auto'. 'auto' has Ink send a CSI `?u` query
65125
+ // to the terminal and then listen on `stdin`'s 'data' event for the
65126
+ // reply (see ink/build/ink.js confirmKittySupport). That listener
65127
+ // races Ink's own input pipeline, which reads stdin via 'readable' +
65128
+ // `.read()` (paused mode) for its normal keystroke handling — Node
65129
+ // streams do not support mixing 'data' (flowing) and 'readable'
65130
+ // (paused) consumers safely, and in practice the 'readable' consumer
65131
+ // wins the terminal's query response. The reply itself (`\x1b[?1u`)
65132
+ // then gets fed through Ink's normal key parser, which doesn't
65133
+ // recognise it as any special key and inserts it as literal text —
65134
+ // this is exactly the "[?1u" that appears in the input box right
65135
+ // after Ink mounts (e.g. right after confirming the trust prompt).
65136
+ //
65137
+ // 'enabled' skips the query/response dance entirely: Ink just writes
65138
+ // the enable sequence once (`\x1b[>1u`) and never listens for a
65139
+ // reply, so there is nothing to leak into the input box. Terminals
65140
+ // that don't support the protocol silently ignore the unrecognised
65141
+ // escape sequence (the same way they ignore bracketed-paste mode),
65142
+ // so Shift+Enter still safely falls back to the '\' continuation
65143
+ // there — this trades "verify support first" for "assume support,
65144
+ // no-op if absent", which is safe here because the only visible
65145
+ // effect of enabling on an unsupporting terminal is nothing at all.
65146
+ kittyKeyboard: { mode: "enabled", flags: ["disambiguateEscapeCodes"] }
65147
+ }
64944
65148
  );
64945
65149
  });
64946
65150
  }
@@ -64979,6 +65183,9 @@ var InkReadlineAdapter = class extends EventEmitter3 {
64979
65183
  this.ink.onInterrupt(() => {
64980
65184
  this.emit("interrupt");
64981
65185
  });
65186
+ this.ink.onExit(() => {
65187
+ this.emit("close");
65188
+ });
64982
65189
  }
64983
65190
  prompt(_preserveCursor) {
64984
65191
  }
@@ -65221,23 +65428,29 @@ var Spinner = class {
65221
65428
  * No-op when the spinner isn't active: progress events can arrive a tick after
65222
65429
  * something else (a tool row, the first text delta) legitimately stopped it, and
65223
65430
  * resurrecting the spinner there would fight the printed output for the status line.
65431
+ *
65432
+ * Deliberately does NOT call render() immediately. onThinkingDelta/onThinkingProgress
65433
+ * can fire many times per second while the model streams (one SSE delta each), and
65434
+ * each of those used to trigger its own Ink re-render (setLive -> React state update ->
65435
+ * full-frame clear/redraw) layered on TOP of the interval's own 100ms redraw already
65436
+ * running in start(). Two independent, unsynchronized render sources hitting the same
65437
+ * frame is exactly what produced the visible jitter users reported ("dòng working...
65438
+ * giật giật liên tục") — every fast delta briefly repainted the status line out of
65439
+ * step with the spinner's own tick. Just updating the field here and letting the
65440
+ * existing setInterval pick it up on its next 100ms tick caps the redraw rate at a
65441
+ * steady 10fps with zero perceptible added latency (worst case: one stale frame for
65442
+ * <100ms), and removes the race entirely.
65224
65443
  */
65225
65444
  setDetail(detail) {
65226
65445
  if (this.interval === null)
65227
65446
  return;
65228
- if (this.detail === detail)
65229
- return;
65230
65447
  this.detail = detail;
65231
- this.render();
65232
65448
  }
65233
- /** Swap the label without resetting the elapsed timer (phase change within one turn). */
65449
+ /** Swap the label without resetting the elapsed timer (phase change within one turn). Same reasoning as setDetail() above: no immediate render, the interval tick picks it up. */
65234
65450
  setText(text) {
65235
65451
  if (this.interval === null)
65236
65452
  return;
65237
- if (this.baseText === text)
65238
- return;
65239
65453
  this.baseText = text;
65240
- this.render();
65241
65454
  }
65242
65455
  render() {
65243
65456
  getInkTerminal()?.setLive(composeStatusLine({
@@ -65695,6 +65908,8 @@ Set ${TRUST_ENV_VAR}=1 to confirm non-interactively, or run \`nex\` interactivel
65695
65908
  process.on("exit", () => {
65696
65909
  stopInkTerminal();
65697
65910
  });
65911
+ if (process.stdin.isTTY)
65912
+ process.stdin.setRawMode(true);
65698
65913
  await startInkTerminal();
65699
65914
  startRowCount();
65700
65915
  }
@@ -65729,15 +65944,20 @@ Set ${TRUST_ENV_VAR}=1 to confirm non-interactively, or run \`nex\` interactivel
65729
65944
  let skills = (0, import_code_core3.loadSkills)(workDir);
65730
65945
  if (skills.length && !headless)
65731
65946
  console.log(source_default.dim(` ${skills.length} skill(s) loaded`));
65732
- try {
65733
- await initMcp(workDir);
65734
- const connected = _mcpManager?.serverNames.length ?? 0;
65735
- const total = _mcpConfig ? Object.keys(_mcpConfig.mcpServers).length : 0;
65736
- if (total && !headless) {
65737
- console.log(source_default.dim(` ${connected}/${total} MCP server(s) connected`) + source_default.dim(" (/mcp for details)"));
65947
+ const mcpStarted = (async () => {
65948
+ try {
65949
+ await initMcp(workDir);
65950
+ const connected = _mcpManager?.serverNames.length ?? 0;
65951
+ const total = _mcpConfig ? Object.keys(_mcpConfig.mcpServers).length : 0;
65952
+ if (total && !headless) {
65953
+ console.log(source_default.dim(` ${connected}/${total} MCP server(s) connected`) + source_default.dim(" (/mcp for details)"));
65954
+ }
65955
+ } catch {
65738
65956
  }
65739
- } catch {
65740
- }
65957
+ })();
65958
+ const mcpReady = () => mcpStarted;
65959
+ if (!interactive)
65960
+ await mcpStarted;
65741
65961
  if (headless && !options.prompt && !options.stdinText) {
65742
65962
  process.stdout.write(JSON.stringify({ type: "error", error: "--output-format json/stream-json requires a one-shot prompt (or piped stdin)." }) + "\n");
65743
65963
  process.exit(1);
@@ -65847,7 +66067,7 @@ ${text}` : "");
65847
66067
  } else {
65848
66068
  console.error(source_default.red("\nError: ") + String(err.message));
65849
66069
  if (salvaged) {
65850
- console.error(source_default.dim(" Progress was saved \u2014 run `nex --continue` to resume this session."));
66070
+ console.error(source_default.dim(" Progress was saved \u2014 run `nex --resume` (or `nex -r`) to resume this session."));
65851
66071
  }
65852
66072
  }
65853
66073
  process.exit(1);
@@ -65860,7 +66080,7 @@ ${text}` : "");
65860
66080
  console.log();
65861
66081
  if (interactive) {
65862
66082
  await flushInkFrame();
65863
- padToBottom(stopRowCount());
66083
+ padToBottom(stopRowCount(), footerText({ mode: agentMode, autoApprove: isYoloMode() }));
65864
66084
  }
65865
66085
  let agentRunning = false;
65866
66086
  const abortSignal = { aborted: false };
@@ -66330,6 +66550,7 @@ ${text}
66330
66550
  rl.pause();
66331
66551
  abortSignal.aborted = false;
66332
66552
  agentRunning = true;
66553
+ await mcpReady();
66333
66554
  try {
66334
66555
  const result = await runTurn(messages, modelAlias, workDir, abortSignal, env3, nexrallMd, agentMode, effortLevel, checkpoints, saveProgress, takePendingInput);
66335
66556
  messages = result.messages;
@@ -66371,6 +66592,7 @@ ${text}
66371
66592
  return;
66372
66593
  }
66373
66594
  case "/mcp": {
66595
+ await mcpReady();
66374
66596
  console.log();
66375
66597
  console.log(source_default.bold(" MCP servers:"));
66376
66598
  console.log(formatMcpStatus());
@@ -66450,6 +66672,7 @@ ${text}
66450
66672
  rl.pause();
66451
66673
  abortSignal.aborted = false;
66452
66674
  agentRunning = true;
66675
+ await mcpReady();
66453
66676
  try {
66454
66677
  const result = await runTurn(messages, turnModel, workDir, abortSignal, env3, nexrallMd, turnMode, effortLevel, checkpoints, saveProgress, takePendingInput);
66455
66678
  messages = result.messages;
@@ -66474,6 +66697,7 @@ ${text}
66474
66697
  rl.pause();
66475
66698
  abortSignal.aborted = false;
66476
66699
  agentRunning = true;
66700
+ await mcpReady();
66477
66701
  try {
66478
66702
  const result = await runTurn(messages, modelAlias, workDir, abortSignal, env3, nexrallMd, agentMode, effortLevel, checkpoints, saveProgress, takePendingInput);
66479
66703
  messages = result.messages;
package/nex-watch.js ADDED
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ // nex-watch — auto-relaunch `nex` if the OS kills it with SIGKILL.
3
+ //
4
+ // Usage:
5
+ // nex-watch [any nex flags/prompt...]
6
+ // nex-watch # interactive session, auto-resumes on kill
7
+ // nex-watch -y "long unattended task"
8
+ //
9
+ // Why this exists (not something `nex` itself can do):
10
+ // SIGKILL (signal 9 — what a shell reports as "killed") is the one POSIX
11
+ // signal a process can NEVER intercept, handle, or clean up after — the
12
+ // kernel tears it down before a single line of the process's own code can
13
+ // run. On macOS this is the Jetsam low-memory killer; on Linux it's the
14
+ // OOM killer. Both pick a victim purely from system-wide memory pressure at
15
+ // that instant — a machine with plenty of total RAM is not immune if
16
+ // something else (browser, IDE, other builds) is using nearly all of it
17
+ // right now. No amount of code inside `nex` can catch or prevent this.
18
+ //
19
+ // What this script actually does:
20
+ // `nex` already persists its conversation to ~/.nexrall/cli-sessions on
21
+ // every turn boundary (≤3s debounce), independent of this script. That
22
+ // means a SIGKILL loses at most a few seconds of progress, not the whole
23
+ // session — but resuming still requires a human to notice the crash and
24
+ // type `nex --resume`. This script automates exactly that: it spawns nex
25
+ // as a child process and watches HOW it exits. Node's child_process
26
+ // reports the terminating signal directly (no exit-code-to-signal
27
+ // guesswork needed) — if nex died from SIGKILL or SIGTERM specifically,
28
+ // this relaunches it with `--resume last` so the run continues from the
29
+ // last saved turn instead of silently stopping. Any OTHER exit (success,
30
+ // a real error, Ctrl+C which nex already handles gracefully) stops the
31
+ // watch loop — this is a safety net for the un-catchable kill case, not a
32
+ // generic "keep retrying forever" wrapper.
33
+ //
34
+ // A capped retry count (not infinite) matters: if the machine is so starved
35
+ // of memory that every relaunch gets killed again within seconds, retrying
36
+ // forever would just contribute to the same memory pressure that caused the
37
+ // first kill. Capping means it gives up loudly instead of thrashing.
38
+ // Cross-platform note: on Windows there is no SIGKILL/SIGTERM in the POSIX
39
+ // sense — a killed process there typically just reports a non-zero exit
40
+ // code with signal:null. This tool's relaunch-on-signal logic is therefore
41
+ // a no-op (and harmless) on Windows; the memory-pressure-kill scenario it
42
+ // guards against is a macOS/Linux OOM-killer phenomenon in the first place.
43
+
44
+ import { spawnSync } from 'child_process';
45
+
46
+ const MAX_RETRIES = Number(process.env.NEX_WATCH_MAX_RETRIES) > 0
47
+ ? Math.floor(Number(process.env.NEX_WATCH_MAX_RETRIES))
48
+ : 5;
49
+ const NEX_BIN = process.env.NEX_WATCH_BIN || 'nex';
50
+
51
+ const isTTY = process.stderr.isTTY === true;
52
+ const c = isTTY
53
+ ? { dim: '\x1b[2m', green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m', reset: '\x1b[0m' }
54
+ : { dim: '', green: '', yellow: '', red: '', reset: '' };
55
+
56
+ const info = (msg) => process.stderr.write(`${c.dim}[nex-watch]${c.reset} ${msg}\n`);
57
+ const warn = (msg) => process.stderr.write(`${c.yellow}[nex-watch]${c.reset} ${msg}\n`);
58
+ const ok = (msg) => process.stderr.write(`${c.green}[nex-watch]${c.reset} ${msg}\n`);
59
+ const fatal = (msg) => { process.stderr.write(`${c.red}[nex-watch]${c.reset} ${msg}\n`); process.exit(1); };
60
+
61
+ // Kill-type signals worth auto-relaunching on. SIGKILL is the un-catchable
62
+ // OOM/Jetsam case this tool exists for; SIGTERM is included because some
63
+ // low-memory managers (and `docker stop`) send it first before escalating —
64
+ // treating it the same way costs nothing extra since nex has no in-flight
65
+ // state to lose beyond what's already persisted.
66
+ const KILL_SIGNALS = new Set(['SIGKILL', 'SIGTERM']);
67
+
68
+ const userArgs = process.argv.slice(2);
69
+
70
+ let attempt = 0;
71
+ for (;;) {
72
+ const args = attempt === 0 ? userArgs : ['--resume', 'last', ...userArgs];
73
+ if (attempt === 0) {
74
+ info(`starting: ${NEX_BIN} ${args.join(' ')}`.trim());
75
+ } else {
76
+ info(`relaunch #${attempt}: ${NEX_BIN} ${args.join(' ')}`.trim());
77
+ }
78
+
79
+ const result = spawnSync(NEX_BIN, args, { stdio: 'inherit' });
80
+
81
+ if (result.error) {
82
+ // e.g. ENOENT — nex isn't on PATH. Not a kill scenario; fail immediately
83
+ // with a clear message rather than retrying something that can't work.
84
+ fatal(`failed to launch '${NEX_BIN}': ${result.error.message}. Install it first: npm install -g nexrall-code`);
85
+ }
86
+
87
+ if (result.signal && KILL_SIGNALS.has(result.signal)) {
88
+ attempt += 1;
89
+ if (attempt > MAX_RETRIES) {
90
+ fatal(
91
+ `nex was killed (${result.signal}) ${MAX_RETRIES} times in a row — giving up. ` +
92
+ `This usually means the machine is critically low on memory; close other applications ` +
93
+ `before retrying. Progress is saved — run 'nex --resume last' manually once memory is free.`
94
+ );
95
+ }
96
+ warn(
97
+ `nex was killed (${result.signal}) — this is the OS's low-memory killer, not a nex bug. ` +
98
+ `Progress up to the last saved turn is safe. Retrying (${attempt}/${MAX_RETRIES}) in 2s...`
99
+ );
100
+ // Synchronous sleep via spawnSync on a no-op — avoids pulling in an async
101
+ // main() just for one delay, keeping this script a simple top-level loop.
102
+ spawnSync(process.platform === 'win32' ? 'timeout' : 'sleep', [process.platform === 'win32' ? '/t 2' : '2'], { stdio: 'ignore', shell: process.platform === 'win32' });
103
+ continue;
104
+ }
105
+
106
+ // Any other outcome — normal exit, a real error, or a signal nex already
107
+ // handles gracefully (e.g. SIGINT from Ctrl+C) — stop here and mirror the
108
+ // child's result so scripts calling nex-watch see the same status nex itself
109
+ // would have produced.
110
+ if (result.status === 0) ok('nex exited normally.');
111
+ if (result.signal) {
112
+ // A non-kill signal (rare) — exit with the conventional 128+n code.
113
+ const SIGNUMS = { SIGINT: 2, SIGHUP: 1, SIGQUIT: 3 };
114
+ process.exit(128 + (SIGNUMS[result.signal] || 0));
115
+ }
116
+ process.exit(result.status ?? 1);
117
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexrall-code",
3
- "version": "0.5.75",
3
+ "version": "0.5.78",
4
4
  "description": "Nexrall Code — AI coding assistant for your terminal (headless agent for scripts, CI and automation)",
5
5
  "keywords": [
6
6
  "ai",
@@ -20,13 +20,15 @@
20
20
  "license": "MIT",
21
21
  "type": "module",
22
22
  "bin": {
23
- "nex": "dist/index.js"
23
+ "nex": "dist/index.js",
24
+ "nex-watch": "nex-watch.js"
24
25
  },
25
26
  "main": "dist/index.js",
26
27
  "files": [
27
28
  "dist",
28
29
  "install.sh",
29
- "install.ps1"
30
+ "install.ps1",
31
+ "nex-watch.js"
30
32
  ],
31
33
  "engines": {
32
34
  "node": ">=18"
@@ -39,7 +41,7 @@
39
41
  "release": "node build.js && node scripts/upload-release.cjs"
40
42
  },
41
43
  "dependencies": {
42
- "@nexrall/code-core": "^1.4.44",
44
+ "@nexrall/code-core": "^1.4.46",
43
45
  "chalk": "^5.3.0",
44
46
  "commander": "^12.0.0",
45
47
  "diff": "^5.2.0",