@nexrall/code-core 1.4.30 → 1.4.31
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.
- package/dist/agent/loop.d.ts +34 -0
- package/dist/agent/loop.d.ts.map +1 -1
- package/dist/agent/loop.js +196 -21
- package/package.json +1 -1
package/dist/agent/loop.d.ts
CHANGED
|
@@ -85,12 +85,46 @@ export declare function stopReasonNotice(reason: StopReason, ctx?: {
|
|
|
85
85
|
repeatError?: string | null;
|
|
86
86
|
}): string | null;
|
|
87
87
|
export declare function resolveMaxIterations(optionValue: number | undefined, settingsRaw: Record<string, unknown>): number;
|
|
88
|
+
/**
|
|
89
|
+
* Whether a bash command should take the repo-state lock.
|
|
90
|
+
*
|
|
91
|
+
* Exported for tests: the failure mode (two concurrent `git commit`) is timing-dependent
|
|
92
|
+
* and painful to reproduce through the real loop.
|
|
93
|
+
*
|
|
94
|
+
* Scans EVERY segment, not just the first word, because the mutating verb is routinely
|
|
95
|
+
* not leading: `cd packages/core && npm publish`, `FOO=1 git push`, `ls && git commit`.
|
|
96
|
+
* Checking only the first token would miss most real commands.
|
|
97
|
+
*/
|
|
98
|
+
export declare function bashNeedsRepoLock(command: string): boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Every path a tool call will touch, so all of them can be locked.
|
|
101
|
+
*
|
|
102
|
+
* `move_file`/`copy_file` carry `source` + `destination`, the rest carry `path`. Both
|
|
103
|
+
* `dest` and `destination` are accepted because the schema has used both spellings and
|
|
104
|
+
* silently missing the key would mean silently losing the lock — a failure that shows
|
|
105
|
+
* up as corrupted content rather than an error.
|
|
106
|
+
*
|
|
107
|
+
* Exported for tests: getting this wrong is invisible until two writes race.
|
|
108
|
+
*/
|
|
109
|
+
export declare function lockPathsFor(name: string, input: Record<string, unknown>, workDir?: string): string[];
|
|
110
|
+
/**
|
|
111
|
+
* Resolve the fan-out limit: env → settings.json → default.
|
|
112
|
+
*
|
|
113
|
+
* Was env-ONLY, the same gap as the sub-task timeout: a project on a small machine that
|
|
114
|
+
* wanted 2, or a big one that wanted 6, had to set a shell variable, and nobody reading
|
|
115
|
+
* settings.json could see what the limit even was. Capped at 16 because this bounds real
|
|
116
|
+
* shared resources (CPU, the API rate limit, file handles) and a typo like 400 should
|
|
117
|
+
* degrade to "a lot" rather than fork-bomb the machine.
|
|
118
|
+
*/
|
|
119
|
+
export declare function resolveMaxConcurrentSubtasks(settingsRaw?: Record<string, unknown>): number;
|
|
88
120
|
/**
|
|
89
121
|
* Minimal concurrency gate. Hand-rolled rather than pulling in `p-limit` because
|
|
90
122
|
* the CLI ships as a single esbuild bundle with no node_modules, and this is a
|
|
91
123
|
* dozen lines.
|
|
92
124
|
*/
|
|
93
125
|
export declare function createLimiter(max: number): <T>(fn: () => Promise<T>) => Promise<T>;
|
|
126
|
+
/** Test-only: forget the memoised limiter so a new limit can take effect. */
|
|
127
|
+
export declare function _resetSubTaskLimiter(): void;
|
|
94
128
|
/**
|
|
95
129
|
* How long a sub-agent may make NO progress before it is stopped.
|
|
96
130
|
*
|
package/dist/agent/loop.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,OAAO,EAKP,UAAU,EACV,gBAAgB,EAChB,UAAU,EACX,MAAM,UAAU,CAAC;AAIlB,OAAO,EAAkE,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAmKrH;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,GAC9C,MAAM,CAKR;AAED,+CAA+C;AAC/C,eAAO,MAAM,YAAY;;;CAAsC,CAAC;AAEhE;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,uBAAuB,CAAC;AAEtD;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB;;;;;;;;;;;;;CAgB3B,CAAC;AAEX,mEAAmE;AACnE,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,gBAAgB,CAAC;CACzB;AAED;;;;;;GAMG;AACH,wBAAsB,uBAAuB,CAC3C,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,OAAO,EAAE,kBAAkB,GAAG,SAAS,EACvC,OAAO,CAAC,EAAE,MAAM,GACf,OAAO,CAAC,UAAU,CAAC,CA8BrB;AAED;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,GAClB,OAAO,GACP,SAAS,GACT,oBAAoB,GACpB,gBAAgB,GAChB,YAAY,GACZ,SAAS,GACT,gBAAgB,GAChB,QAAQ,GACR,SAAS,CAAC;AAEd;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,UAAU,EAClB,GAAG,GAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAO,GACzD,MAAM,GAAG,IAAI,CAgCf;AAWD,wBAAgB,oBAAoB,CAClC,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,MAAM,CAWR;
|
|
1
|
+
{"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"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"}
|
package/dist/agent/loop.js
CHANGED
|
@@ -38,7 +38,11 @@ exports.errorRoundSignature = errorRoundSignature;
|
|
|
38
38
|
exports.executeAgentMemoryWrite = executeAgentMemoryWrite;
|
|
39
39
|
exports.stopReasonNotice = stopReasonNotice;
|
|
40
40
|
exports.resolveMaxIterations = resolveMaxIterations;
|
|
41
|
+
exports.bashNeedsRepoLock = bashNeedsRepoLock;
|
|
42
|
+
exports.lockPathsFor = lockPathsFor;
|
|
43
|
+
exports.resolveMaxConcurrentSubtasks = resolveMaxConcurrentSubtasks;
|
|
41
44
|
exports.createLimiter = createLimiter;
|
|
45
|
+
exports._resetSubTaskLimiter = _resetSubTaskLimiter;
|
|
42
46
|
exports.resolveSubtaskTimeoutMs = resolveSubtaskTimeoutMs;
|
|
43
47
|
exports.extractSubTaskText = extractSubTaskText;
|
|
44
48
|
exports.capSubTaskText = capSubTaskText;
|
|
@@ -387,6 +391,113 @@ async function withFileLock(absPath, fn) {
|
|
|
387
391
|
_fileLocks.delete(absPath);
|
|
388
392
|
}
|
|
389
393
|
}
|
|
394
|
+
/**
|
|
395
|
+
* Take several locks at once, always in a globally consistent order.
|
|
396
|
+
*
|
|
397
|
+
* Needed because move_file/copy_file touch TWO paths. Locking only one of them (the
|
|
398
|
+
* old behaviour locked `source` and left `dest` unprotected) leaves exactly the race
|
|
399
|
+
* the lock exists to prevent: a `move_file{dest:'shared.ts'}` running concurrently with
|
|
400
|
+
* an `edit_file{path:'shared.ts'}` had nothing serialising them.
|
|
401
|
+
*
|
|
402
|
+
* The sort is load-bearing, not tidiness: two callers acquiring {A,B} and {B,A} at the
|
|
403
|
+
* same time would deadlock, each holding what the other waits for. Sorting means every
|
|
404
|
+
* caller in the process takes them in the same order, which makes that impossible.
|
|
405
|
+
*/
|
|
406
|
+
async function withFileLocks(absPaths, fn) {
|
|
407
|
+
const unique = [...new Set(absPaths.filter(Boolean))].sort();
|
|
408
|
+
if (unique.length === 0)
|
|
409
|
+
return fn();
|
|
410
|
+
const [first, ...rest] = unique;
|
|
411
|
+
return withFileLock(first, () => (rest.length ? withFileLocks(rest, fn) : fn()));
|
|
412
|
+
}
|
|
413
|
+
/** Tools that mutate the filesystem and must be serialised per path. */
|
|
414
|
+
const WRITE_TOOLS = new Set([
|
|
415
|
+
'write_file', 'edit_file', 'multi_edit', 'delete_file', 'move_file', 'copy_file', 'notebook_edit',
|
|
416
|
+
]);
|
|
417
|
+
/**
|
|
418
|
+
* Shared lock key for bash commands that mutate repository-wide state.
|
|
419
|
+
*
|
|
420
|
+
* Not a path, because these commands do not declare one — `git commit` contends over
|
|
421
|
+
* `.git/index.lock`, `npm install` over `node_modules` and the lockfile. One key means
|
|
422
|
+
* they serialise against each other while everything else stays parallel.
|
|
423
|
+
*/
|
|
424
|
+
const REPO_STATE_LOCK = '\u0000repo-state';
|
|
425
|
+
/**
|
|
426
|
+
* Commands that must not run concurrently with another instance of themselves.
|
|
427
|
+
*
|
|
428
|
+
* `bash` was excluded from the write-tool lock entirely, which was the largest hole in
|
|
429
|
+
* the concurrency story: it is the most mutating tool there is, and with 4 sub-agents
|
|
430
|
+
* fanned out two of them could run `git commit` simultaneously (one dies on
|
|
431
|
+
* `.git/index.lock`), or two `npm install` (corrupted node_modules / lockfile), or two
|
|
432
|
+
* `npm test` writing the same coverage and snapshot files (wrong results, not an error).
|
|
433
|
+
* The comment above withFileLock explains carefully why two edit_file calls must be
|
|
434
|
+
* sequenced, then a `bash -c 'echo x > file'` did exactly that with nothing holding it.
|
|
435
|
+
*
|
|
436
|
+
* Deliberately a VERB ALLOWLIST rather than serialising all of bash: an explorer's
|
|
437
|
+
* `grep`/`rg`/`ls`/`cat` are read-only and are precisely the calls that benefit most
|
|
438
|
+
* from running in parallel. Serialising those would trade a real correctness bug for a
|
|
439
|
+
* real performance regression. These verbs are the ones that take a global lock, write
|
|
440
|
+
* a lockfile, or mutate a shared build/dependency directory.
|
|
441
|
+
*/
|
|
442
|
+
const MUTATING_BASH_VERBS = new Set([
|
|
443
|
+
'git', 'npm', 'pnpm', 'yarn', 'bun', 'cargo', 'go', 'pip', 'pip3', 'poetry', 'uv',
|
|
444
|
+
'bundle', 'composer', 'gradle', 'mvn', 'make', 'terraform', 'helm', 'docker',
|
|
445
|
+
'vsce', 'tsc', 'pytest', 'dotnet', 'swift', 'gem',
|
|
446
|
+
]);
|
|
447
|
+
/**
|
|
448
|
+
* Whether a bash command should take the repo-state lock.
|
|
449
|
+
*
|
|
450
|
+
* Exported for tests: the failure mode (two concurrent `git commit`) is timing-dependent
|
|
451
|
+
* and painful to reproduce through the real loop.
|
|
452
|
+
*
|
|
453
|
+
* Scans EVERY segment, not just the first word, because the mutating verb is routinely
|
|
454
|
+
* not leading: `cd packages/core && npm publish`, `FOO=1 git push`, `ls && git commit`.
|
|
455
|
+
* Checking only the first token would miss most real commands.
|
|
456
|
+
*/
|
|
457
|
+
function bashNeedsRepoLock(command) {
|
|
458
|
+
if (!command)
|
|
459
|
+
return false;
|
|
460
|
+
// Split on shell separators, then take the first bare word of each segment, skipping
|
|
461
|
+
// env-var assignments (FOO=bar git push) and `sudo`/`time`-style prefixes.
|
|
462
|
+
const segments = command.split(/\|\||&&|[;|&\n()]|\$\(/);
|
|
463
|
+
for (const seg of segments) {
|
|
464
|
+
for (const tokenRaw of seg.trim().split(/\s+/)) {
|
|
465
|
+
const token = tokenRaw.replace(/^["']|["']$/g, '');
|
|
466
|
+
if (!token)
|
|
467
|
+
continue;
|
|
468
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token))
|
|
469
|
+
continue; // env assignment
|
|
470
|
+
if (token === 'sudo' || token === 'time' || token === 'nice' || token === 'env')
|
|
471
|
+
continue;
|
|
472
|
+
// Use the basename so `/usr/bin/git` and `./node_modules/.bin/tsc` still match.
|
|
473
|
+
const verb = token.split('/').pop() ?? token;
|
|
474
|
+
if (MUTATING_BASH_VERBS.has(verb))
|
|
475
|
+
return true;
|
|
476
|
+
break; // only the command word of each segment matters
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Every path a tool call will touch, so all of them can be locked.
|
|
483
|
+
*
|
|
484
|
+
* `move_file`/`copy_file` carry `source` + `destination`, the rest carry `path`. Both
|
|
485
|
+
* `dest` and `destination` are accepted because the schema has used both spellings and
|
|
486
|
+
* silently missing the key would mean silently losing the lock — a failure that shows
|
|
487
|
+
* up as corrupted content rather than an error.
|
|
488
|
+
*
|
|
489
|
+
* Exported for tests: getting this wrong is invisible until two writes race.
|
|
490
|
+
*/
|
|
491
|
+
function lockPathsFor(name, input, workDir) {
|
|
492
|
+
if (!WRITE_TOOLS.has(name))
|
|
493
|
+
return [];
|
|
494
|
+
const raw = [input.path, input.source, input.destination, input.dest]
|
|
495
|
+
.filter((p) => typeof p === 'string' && p.length > 0);
|
|
496
|
+
const abs = raw.map((p) => (workDir ? path.resolve(workDir, p) : p));
|
|
497
|
+
// Fall back to the tool name so a malformed call still serialises against itself
|
|
498
|
+
// rather than escaping the lock entirely.
|
|
499
|
+
return abs.length ? abs : [name];
|
|
500
|
+
}
|
|
390
501
|
// ─── Sub-agent fan-out limiter ────────────────────────────────────────────────
|
|
391
502
|
//
|
|
392
503
|
// Tool calls in one turn run via Promise.all with no ceiling. For ordinary tools
|
|
@@ -407,10 +518,26 @@ async function withFileLock(absPath, fn) {
|
|
|
407
518
|
//
|
|
408
519
|
// This is a QUEUE, not a rejection: every sub-task still runs, just at most N at a
|
|
409
520
|
// time. Failing the excess would be worse than serialising it.
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
521
|
+
const DEFAULT_MAX_CONCURRENT_SUBTASKS = 4;
|
|
522
|
+
/**
|
|
523
|
+
* Resolve the fan-out limit: env → settings.json → default.
|
|
524
|
+
*
|
|
525
|
+
* Was env-ONLY, the same gap as the sub-task timeout: a project on a small machine that
|
|
526
|
+
* wanted 2, or a big one that wanted 6, had to set a shell variable, and nobody reading
|
|
527
|
+
* settings.json could see what the limit even was. Capped at 16 because this bounds real
|
|
528
|
+
* shared resources (CPU, the API rate limit, file handles) and a typo like 400 should
|
|
529
|
+
* degrade to "a lot" rather than fork-bomb the machine.
|
|
530
|
+
*/
|
|
531
|
+
function resolveMaxConcurrentSubtasks(settingsRaw = {}) {
|
|
532
|
+
const clamp = (n) => Math.min(Math.floor(n), 16);
|
|
533
|
+
const fromEnv = Number(process.env.NEXRALL_MAX_CONCURRENT_SUBTASKS);
|
|
534
|
+
if (Number.isFinite(fromEnv) && fromEnv > 0)
|
|
535
|
+
return clamp(fromEnv);
|
|
536
|
+
const fromSettings = Number(settingsRaw.maxConcurrentSubtasks);
|
|
537
|
+
if (Number.isFinite(fromSettings) && fromSettings > 0)
|
|
538
|
+
return clamp(fromSettings);
|
|
539
|
+
return DEFAULT_MAX_CONCURRENT_SUBTASKS;
|
|
540
|
+
}
|
|
414
541
|
/**
|
|
415
542
|
* Minimal concurrency gate. Hand-rolled rather than pulling in `p-limit` because
|
|
416
543
|
* the CLI ships as a single esbuild bundle with no node_modules, and this is a
|
|
@@ -438,7 +565,28 @@ function createLimiter(max) {
|
|
|
438
565
|
// Process-wide, deliberately: the limit exists to protect shared resources (CPU,
|
|
439
566
|
// the API rate limit, file handles), and those are shared across every concurrent
|
|
440
567
|
// turn in this process, not just the tool calls of one message.
|
|
441
|
-
|
|
568
|
+
//
|
|
569
|
+
// Built LAZILY on first use rather than at module load, because the limit can now come
|
|
570
|
+
// from settings.json and the workspace is not known when this module is imported.
|
|
571
|
+
// Once created it is reused for the process lifetime — rebuilding it per turn would
|
|
572
|
+
// reset `active` and let the ceiling be exceeded, which is worse than not honouring a
|
|
573
|
+
// mid-session settings change.
|
|
574
|
+
let _subTaskLimitInstance = null;
|
|
575
|
+
let _subTaskLimitMax = 0;
|
|
576
|
+
/** Sub-tasks dispatched but not yet finished — used only to detect queueing. */
|
|
577
|
+
let _inFlightSubTasks = 0;
|
|
578
|
+
function subTaskLimiter(workDir) {
|
|
579
|
+
if (!_subTaskLimitInstance) {
|
|
580
|
+
_subTaskLimitMax = resolveMaxConcurrentSubtasks(workDir ? (0, rules_1.loadSettings)(workDir).raw : {});
|
|
581
|
+
_subTaskLimitInstance = createLimiter(_subTaskLimitMax);
|
|
582
|
+
}
|
|
583
|
+
return { run: _subTaskLimitInstance, max: _subTaskLimitMax };
|
|
584
|
+
}
|
|
585
|
+
/** Test-only: forget the memoised limiter so a new limit can take effect. */
|
|
586
|
+
function _resetSubTaskLimiter() {
|
|
587
|
+
_subTaskLimitInstance = null;
|
|
588
|
+
_subTaskLimitMax = 0;
|
|
589
|
+
}
|
|
442
590
|
// ─── Human-readable tool descriptions ────────────────────────────────────────
|
|
443
591
|
function humanDescription(name, input) {
|
|
444
592
|
switch (name) {
|
|
@@ -2426,9 +2574,30 @@ async function runAgentLoop(initialMessages, options) {
|
|
|
2426
2574
|
// waiting on a child that can never be scheduled, the run would wedge
|
|
2427
2575
|
// permanently. Keeping the limiter acyclic (a holder never re-enters it)
|
|
2428
2576
|
// means raising MAX_TASK_DEPTH later can't silently reintroduce that.
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2577
|
+
if (depth === 0) {
|
|
2578
|
+
const { run: limitRun, max: limitMax } = subTaskLimiter(options.workDir);
|
|
2579
|
+
// Tell the user when a sub-task is WAITING rather than working.
|
|
2580
|
+
//
|
|
2581
|
+
// With a burst of 8 and a ceiling of 4, the last four sat silently in the
|
|
2582
|
+
// queue. From the outside they looked started, so a long wait read as a
|
|
2583
|
+
// hang — and the fix for a hang (Ctrl+C and retry) is exactly wrong here,
|
|
2584
|
+
// since the work was about to run. `_inFlightSubTasks` is only ever
|
|
2585
|
+
// touched from this single dispatch point, so the count is exact.
|
|
2586
|
+
if (_inFlightSubTasks >= limitMax) {
|
|
2587
|
+
(options.onNotice ?? options.onText)(`\u23f3 Queued: ${limitMax} sub-agents are already running, so this one starts when a ` +
|
|
2588
|
+
`slot frees up (raise "maxConcurrentSubtasks" in .nexrall/settings.json to widen it).`);
|
|
2589
|
+
}
|
|
2590
|
+
_inFlightSubTasks++;
|
|
2591
|
+
try {
|
|
2592
|
+
result = await limitRun(() => runSubTask(input, options, agentTypes));
|
|
2593
|
+
}
|
|
2594
|
+
finally {
|
|
2595
|
+
_inFlightSubTasks--;
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
else {
|
|
2599
|
+
result = await runSubTask(input, options, agentTypes);
|
|
2600
|
+
}
|
|
2432
2601
|
}
|
|
2433
2602
|
else {
|
|
2434
2603
|
const pre = runToolHooks(hooks.PreToolUse, 'PreToolUse', name, input, options.workDir);
|
|
@@ -2462,22 +2631,28 @@ async function runAgentLoop(initialMessages, options) {
|
|
|
2462
2631
|
else {
|
|
2463
2632
|
// Snapshot pre-mutation state so the user can /rewind this turn.
|
|
2464
2633
|
options.checkpointManager?.recordBeforeMutation(name, input);
|
|
2465
|
-
// Serialise writes to the same file: if the model emits two
|
|
2466
|
-
// edit_file/write_file calls for the same path in one turn
|
|
2467
|
-
// (executed via Promise.all), the second would overwrite the
|
|
2468
|
-
// first. withFileLock queues them so they apply sequentially.
|
|
2469
|
-
const WRITE_TOOLS = new Set(['write_file', 'edit_file', 'multi_edit', 'delete_file', 'move_file', 'copy_file', 'notebook_edit']);
|
|
2470
2634
|
const onStream = options.onToolStreamChunk
|
|
2471
2635
|
? (chunk) => options.onToolStreamChunk(name, chunk)
|
|
2472
2636
|
: undefined;
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2637
|
+
const run = () => (0, executor_1.executeTool)(name, input, options.abortSignal, sandboxCfg, options.workDir, agentScope, onStream);
|
|
2638
|
+
// Serialise anything that would otherwise race. Two sources of races:
|
|
2639
|
+
//
|
|
2640
|
+
// • Writes to the SAME PATH — the model emits multiple tool_use blocks
|
|
2641
|
+
// per turn and they run via Promise.all, so two edit_file calls on
|
|
2642
|
+
// one file would both read the original and the second would discard
|
|
2643
|
+
// the first. Now ALL paths a call touches are locked (move_file and
|
|
2644
|
+
// copy_file have two), in a globally consistent order.
|
|
2645
|
+
// • Repo-wide state via bash — `git commit`, `npm install`, `npm test`.
|
|
2646
|
+
// These declare no path, so they take one shared lock. Read-only
|
|
2647
|
+
// commands (grep, ls, cat) are excluded and stay parallel, which is
|
|
2648
|
+
// what an explorer agent spends nearly all its calls on.
|
|
2649
|
+
//
|
|
2650
|
+
// This matters far more with sub-agents than without: up to 4 run
|
|
2651
|
+
// concurrently, each issuing its own tool calls into this same process.
|
|
2652
|
+
const locks = name === 'bash'
|
|
2653
|
+
? (bashNeedsRepoLock(String(input.command ?? '')) ? [REPO_STATE_LOCK] : [])
|
|
2654
|
+
: lockPathsFor(name, input, options.workDir);
|
|
2655
|
+
result = locks.length ? await withFileLocks(locks, run) : await run();
|
|
2481
2656
|
}
|
|
2482
2657
|
}
|
|
2483
2658
|
catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nexrall/code-core",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.31",
|
|
4
4
|
"description": "Core agent loop, tools, and extension primitives for Nexrall Code — embed an AI coding agent in any Node.js application.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Nexrall <support@nexrall.com> (https://nexrall.com)",
|