@nexrall/code-core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +155 -0
  3. package/dist/agent/agentTypes.d.ts +17 -0
  4. package/dist/agent/agentTypes.d.ts.map +1 -0
  5. package/dist/agent/agentTypes.js +156 -0
  6. package/dist/agent/loop.d.ts +3 -0
  7. package/dist/agent/loop.d.ts.map +1 -0
  8. package/dist/agent/loop.js +775 -0
  9. package/dist/api/client.d.ts +27 -0
  10. package/dist/api/client.d.ts.map +1 -0
  11. package/dist/api/client.js +414 -0
  12. package/dist/auth/index.d.ts +7 -0
  13. package/dist/auth/index.d.ts.map +1 -0
  14. package/dist/auth/index.js +95 -0
  15. package/dist/checkpoint/manager.d.ts +86 -0
  16. package/dist/checkpoint/manager.d.ts.map +1 -0
  17. package/dist/checkpoint/manager.js +409 -0
  18. package/dist/commands/loader.d.ts +18 -0
  19. package/dist/commands/loader.d.ts.map +1 -0
  20. package/dist/commands/loader.js +183 -0
  21. package/dist/index.d.ts +14 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +30 -0
  24. package/dist/mcp/client.d.ts +46 -0
  25. package/dist/mcp/client.d.ts.map +1 -0
  26. package/dist/mcp/client.js +128 -0
  27. package/dist/mcp/httpClient.d.ts +25 -0
  28. package/dist/mcp/httpClient.d.ts.map +1 -0
  29. package/dist/mcp/httpClient.js +143 -0
  30. package/dist/mcp/manager.d.ts +56 -0
  31. package/dist/mcp/manager.d.ts.map +1 -0
  32. package/dist/mcp/manager.js +234 -0
  33. package/dist/mcp/sseClient.d.ts +30 -0
  34. package/dist/mcp/sseClient.d.ts.map +1 -0
  35. package/dist/mcp/sseClient.js +224 -0
  36. package/dist/permissions/rules.d.ts +20 -0
  37. package/dist/permissions/rules.d.ts.map +1 -0
  38. package/dist/permissions/rules.js +218 -0
  39. package/dist/plugins/index.d.ts +18 -0
  40. package/dist/plugins/index.d.ts.map +1 -0
  41. package/dist/plugins/index.js +139 -0
  42. package/dist/tools/executor.d.ts +6 -0
  43. package/dist/tools/executor.d.ts.map +1 -0
  44. package/dist/tools/executor.js +1458 -0
  45. package/dist/tools/sandbox.d.ts +13 -0
  46. package/dist/tools/sandbox.d.ts.map +1 -0
  47. package/dist/tools/sandbox.js +140 -0
  48. package/dist/tools/symbols.d.ts +13 -0
  49. package/dist/tools/symbols.d.ts.map +1 -0
  50. package/dist/tools/symbols.js +279 -0
  51. package/dist/types.d.ts +186 -0
  52. package/dist/types.d.ts.map +1 -0
  53. package/dist/types.js +4 -0
  54. package/package.json +92 -0
@@ -0,0 +1,775 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.runAgentLoop = runAgentLoop;
37
+ const client_1 = require("../api/client");
38
+ const executor_1 = require("../tools/executor");
39
+ const agentTypes_1 = require("./agentTypes");
40
+ const rules_1 = require("../permissions/rules");
41
+ const sandbox_1 = require("../tools/sandbox");
42
+ const index_1 = require("../plugins/index");
43
+ const fs = __importStar(require("fs"));
44
+ const path = __importStar(require("path"));
45
+ const child_process_1 = require("child_process");
46
+ function loadHooks(workDir) {
47
+ let fromSettings = {};
48
+ try {
49
+ const p = path.join(workDir, '.nexrall', 'settings.json');
50
+ if (fs.existsSync(p))
51
+ fromSettings = JSON.parse(fs.readFileSync(p, 'utf-8')).hooks ?? {};
52
+ }
53
+ catch { /* ignore */ }
54
+ // Merge plugin-provided hooks AFTER the project's own (project hooks run first).
55
+ const fromPlugins = (0, index_1.pluginHooks)(workDir);
56
+ const merged = { ...fromSettings };
57
+ for (const phase of Object.keys(fromPlugins)) {
58
+ const extra = fromPlugins[phase];
59
+ if (!Array.isArray(extra) || !extra.length)
60
+ continue;
61
+ merged[phase] = [
62
+ ...((merged[phase]) ?? []),
63
+ ...extra,
64
+ ];
65
+ }
66
+ return merged;
67
+ }
68
+ // Run PreToolUse / PostToolUse hooks with a Claude-Code-style control protocol.
69
+ //
70
+ // Each hook command receives a JSON payload on stdin and NEXRALL_TOOL_* env vars.
71
+ // It controls the agent via:
72
+ // β€’ exit code 2 β†’ BLOCK the tool; stderr becomes the reason
73
+ // β€’ stdout JSON object β†’ { "decision": "block"|"allow", "reason": "...",
74
+ // "additionalContext": "text to feed the model" }
75
+ // β€’ any other exit code β†’ non-blocking (stderr logged, tool proceeds)
76
+ function runToolHooks(entries, phase, toolName, input, workDir, result) {
77
+ const outcome = { block: false };
78
+ if (!entries?.length)
79
+ return outcome;
80
+ const payload = JSON.stringify({
81
+ phase,
82
+ tool: toolName,
83
+ input,
84
+ ...(result ? { result: { output: result.output, error: result.error } } : {}),
85
+ });
86
+ for (const entry of entries) {
87
+ if (entry.matcher && entry.matcher !== '*' && !toolName.includes(entry.matcher))
88
+ continue;
89
+ for (const hook of entry.hooks ?? []) {
90
+ if (hook.type !== 'command' || !hook.command)
91
+ continue;
92
+ let r;
93
+ try {
94
+ r = (0, child_process_1.spawnSync)(hook.command, {
95
+ shell: true,
96
+ cwd: workDir,
97
+ timeout: 10000,
98
+ encoding: 'utf-8',
99
+ input: payload,
100
+ env: {
101
+ ...process.env,
102
+ NEXRALL_TOOL_NAME: toolName,
103
+ NEXRALL_TOOL_INPUT: JSON.stringify(input),
104
+ NEXRALL_HOOK_PHASE: phase,
105
+ },
106
+ });
107
+ }
108
+ catch {
109
+ continue; // hook itself failed to spawn β€” non-fatal
110
+ }
111
+ // Optional JSON directive on stdout
112
+ const out = (r.stdout ?? '').toString().trim();
113
+ if (out.startsWith('{')) {
114
+ try {
115
+ const j = JSON.parse(out);
116
+ if (j.decision === 'block') {
117
+ outcome.block = true;
118
+ outcome.reason = j.reason ?? outcome.reason ?? 'Blocked by hook';
119
+ }
120
+ if (typeof j.additionalContext === 'string' && j.additionalContext) {
121
+ outcome.context = (outcome.context ? outcome.context + '\n' : '') + j.additionalContext;
122
+ }
123
+ }
124
+ catch { /* not a directive β€” ignore */ }
125
+ }
126
+ // Exit code 2 β†’ hard block; stderr is the reason fed back to the model
127
+ if (r.status === 2) {
128
+ outcome.block = true;
129
+ const err = (r.stderr ?? '').toString().trim();
130
+ outcome.reason = err || outcome.reason || `Blocked by ${phase} hook`;
131
+ }
132
+ }
133
+ }
134
+ return outcome;
135
+ }
136
+ function runSimpleHooks(defs, workDir) {
137
+ if (!defs?.length)
138
+ return;
139
+ for (const hook of defs) {
140
+ if (hook.type === 'command' && hook.command) {
141
+ try {
142
+ (0, child_process_1.spawnSync)(hook.command, { shell: true, cwd: workDir, timeout: 10000 });
143
+ }
144
+ catch { /* non-fatal */ }
145
+ }
146
+ }
147
+ }
148
+ // ─── Iteration cap ──────────────────────────────────────────────────────────
149
+ // Each iteration is one model response + one round of tool execution. The cap is
150
+ // a runaway-loop backstop, NOT a task-size limit β€” on a large project a single
151
+ // task can legitimately need well over 50 rounds (read β†’ search β†’ edit β†’ test β†’
152
+ // fix β†’ …). A too-low cap makes the agent appear to "freeze" mid-task. Keep the
153
+ // default high and let projects raise it further via settings / env.
154
+ const DEFAULT_MAX_ITERATIONS = 500;
155
+ const MAX_ITERATIONS_CEILING = 2000; // absolute backstop β€” auto-continue never goes past this
156
+ const STALL_LIMIT = 8; // consecutive all-failed tool rounds β†’ give up (runaway guard)
157
+ // Resolve the soft iteration budget. Precedence:
158
+ // options.maxIterations β†’ env NEXRALL_MAX_ITERATIONS β†’ settings.maxIterations β†’ default
159
+ function resolveMaxIterations(optionValue, settingsRaw) {
160
+ const fromEnv = Number(process.env.NEXRALL_MAX_ITERATIONS);
161
+ const fromSettings = Number(settingsRaw.maxIterations);
162
+ const candidate = (typeof optionValue === 'number' && optionValue > 0) ? optionValue
163
+ : Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv
164
+ : Number.isFinite(fromSettings) && fromSettings > 0 ? fromSettings
165
+ : DEFAULT_MAX_ITERATIONS;
166
+ return Math.min(Math.floor(candidate), MAX_ITERATIONS_CEILING);
167
+ }
168
+ // When the soft budget is exhausted with work still pending, keep going instead
169
+ // of stopping. Precedence: options.autoContinue β†’ env NEXRALL_AUTO_CONTINUE β†’
170
+ // settings.autoContinue β†’ default (on). Bounded by STALL_LIMIT and the ceiling.
171
+ function resolveAutoContinue(optionValue, settingsRaw) {
172
+ if (typeof optionValue === 'boolean')
173
+ return optionValue;
174
+ const env = process.env.NEXRALL_AUTO_CONTINUE;
175
+ if (env === '0' || env === 'false')
176
+ return false;
177
+ if (env === '1' || env === 'true')
178
+ return true;
179
+ const s = settingsRaw.autoContinue;
180
+ if (typeof s === 'boolean')
181
+ return s;
182
+ return true;
183
+ }
184
+ // ─── Per-file write lock ──────────────────────────────────────────────────────
185
+ // When the model emits multiple tool_use blocks in one turn (executed via
186
+ // Promise.all), two edits to the same file race: both read the original, both
187
+ // write their version, and the second write silently discards the first edit.
188
+ // This lock serialises writes per absolute path to prevent that.
189
+ const _fileLocks = new Map();
190
+ async function withFileLock(absPath, fn) {
191
+ const prev = _fileLocks.get(absPath) ?? Promise.resolve();
192
+ let releaseLock;
193
+ const next = new Promise((res) => { releaseLock = res; });
194
+ _fileLocks.set(absPath, prev.then(() => next));
195
+ try {
196
+ await prev; // wait for any in-flight operation on this file
197
+ return await fn();
198
+ }
199
+ finally {
200
+ releaseLock();
201
+ // Cleanup: remove the entry once the chain is idle to avoid unbounded growth
202
+ if (_fileLocks.get(absPath) === next)
203
+ _fileLocks.delete(absPath);
204
+ }
205
+ }
206
+ // ─── Human-readable tool descriptions ────────────────────────────────────────
207
+ function humanDescription(name, input) {
208
+ switch (name) {
209
+ case 'read_file':
210
+ return `Read file: ${input.path ?? '(unknown)'}`;
211
+ case 'write_file': {
212
+ const content = typeof input.content === 'string' ? input.content : '';
213
+ const bytes = Buffer.byteLength(content, 'utf-8');
214
+ return `Write file: ${input.path ?? '(unknown)'} (${bytes} bytes)`;
215
+ }
216
+ case 'list_directory':
217
+ return `List directory: ${input.path ?? '.'}`;
218
+ case 'bash':
219
+ return `Run: ${input.command ?? '(unknown)'}`;
220
+ case 'search_files': {
221
+ const searchType = input.type === 'filename' ? 'filename' : 'content';
222
+ const inPath = input.path ? ` in ${input.path}` : '';
223
+ return `Search ${searchType}: "${input.pattern ?? ''}"${inPath}`;
224
+ }
225
+ case 'create_directory':
226
+ return `Create directory: ${input.path ?? '(unknown)'}`;
227
+ case 'move_file':
228
+ return `Move file: ${input.source ?? '(unknown)'} β†’ ${input.dest ?? '(unknown)'}`;
229
+ case 'copy_file':
230
+ return `Copy file: ${input.source ?? '(unknown)'} β†’ ${input.destination ?? '(unknown)'}`;
231
+ case 'edit_file':
232
+ return `Edit file: ${input.path ?? '(unknown)'}`;
233
+ case 'multi_edit': {
234
+ const edits = Array.isArray(input.edits) ? input.edits : [];
235
+ return `Multi-edit file: ${input.path ?? '(unknown)'} (${edits.length} change${edits.length !== 1 ? 's' : ''})`;
236
+ }
237
+ case 'glob':
238
+ return `Glob: ${input.pattern ?? '(unknown)'}${input.path ? ` in ${input.path}` : ''}`;
239
+ case 'todo_write': {
240
+ const todos = Array.isArray(input.todos) ? input.todos : [];
241
+ return `Update task list (${todos.length} item${todos.length !== 1 ? 's' : ''})`;
242
+ }
243
+ case 'todo_read':
244
+ return 'Read task list';
245
+ case 'notebook_read':
246
+ return `Read notebook: ${input.path ?? '(unknown)'}`;
247
+ case 'notebook_edit': {
248
+ const t = input.edit_type ?? 'edit';
249
+ return `Notebook ${t}: ${input.path ?? '(unknown)'} cell ${input.cell_index ?? '?'}`;
250
+ }
251
+ case 'delete_file':
252
+ return `Delete file: ${input.path ?? '(unknown)'}`;
253
+ case 'bash_output':
254
+ return `Read background shell: ${input.shell_id ?? '(unknown)'}`;
255
+ case 'kill_shell':
256
+ return `Kill background shell: ${input.shell_id ?? '(unknown)'}`;
257
+ case 'fetch_url':
258
+ return `Fetch URL: ${input.url ?? '(unknown)'}`;
259
+ case 'generate_image':
260
+ return `Generate image β†’ ${input.path ?? '(unknown)'}`;
261
+ case 'stock_photo':
262
+ return input.path
263
+ ? `Stock photo "${input.query ?? ''}" β†’ ${input.path}`
264
+ : `Search stock photos: "${input.query ?? ''}"`;
265
+ case 'task': {
266
+ const desc = typeof input.description === 'string' ? input.description : '';
267
+ const preview = typeof input.prompt === 'string' ? input.prompt.slice(0, 60) : '';
268
+ return `Sub-task: ${desc || preview}${!desc && preview.length === 60 ? '…' : ''}`;
269
+ }
270
+ case 'get_diagnostics':
271
+ return input.path ? `Get diagnostics: ${input.path}` : 'Get workspace diagnostics';
272
+ case 'go_to_definition':
273
+ return `Go to definition: ${input.path ?? ''}:${input.line ?? ''}:${input.character ?? ''}`;
274
+ case 'find_references':
275
+ return `Find references: ${input.path ?? ''}:${input.line ?? ''}:${input.character ?? ''}`;
276
+ case 'get_symbols':
277
+ return `Get symbols: ${input.path ?? '(unknown)'}`;
278
+ case 'get_workspace_symbols':
279
+ return `Search symbols: "${input.query ?? ''}"`;
280
+ case 'get_hover':
281
+ return `Get hover info: ${input.path ?? ''}:${input.line ?? ''}:${input.character ?? ''}`;
282
+ case 'open_in_browser':
283
+ return `Open in browser: ${input.url ?? '(unknown)'}`;
284
+ default:
285
+ return `Use tool: ${name}`;
286
+ }
287
+ }
288
+ // ─── Sub-task runner ──────────────────────────────────────────────────────────
289
+ const MAX_TASK_DEPTH = 2; // main agent (0) can spawn sub-agents (1) β€” sub-agents cannot spawn further
290
+ let _subTaskCounter = 0; // unique per-process id β†’ per-sub-agent todo scope
291
+ async function runSubTask(input, options, agentTypes) {
292
+ const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : '';
293
+ if (!prompt)
294
+ return { error: 'task tool requires a non-empty prompt' };
295
+ const depth = options._depth ?? 0;
296
+ if (depth >= MAX_TASK_DEPTH) {
297
+ return { error: `Sub-task depth limit (${MAX_TASK_DEPTH}) reached β€” sub-agents cannot spawn further sub-agents.` };
298
+ }
299
+ // Resolve an optional custom agent type (subagent_type).
300
+ const requestedType = typeof input.subagent_type === 'string' ? input.subagent_type : '';
301
+ const agent = (0, agentTypes_1.findAgentType)(agentTypes, requestedType);
302
+ if (requestedType && !agent) {
303
+ const known = agentTypes.map((a) => a.name).join(', ') || '(none defined)';
304
+ return { error: `Unknown subagent_type "${requestedType}". Available types: ${known}.` };
305
+ }
306
+ // A custom agent's persona is delivered through the project-instructions
307
+ // channel (authoritative in the system prompt), layered above the project's
308
+ // own nexrall.md so it keeps project conventions.
309
+ const subNexrallMd = agent
310
+ ? `# Sub-agent role: ${agent.name}\n${agent.prompt}` +
311
+ (options.nexrallMd ? `\n\n---\n\n${options.nexrallMd}` : '')
312
+ : options.nexrallMd;
313
+ // Optional tool allowlist β€” deny anything outside it for this sub-agent.
314
+ const allowed = agent?.tools ? new Set(agent.tools) : null;
315
+ const gatedPermission = async (req) => {
316
+ if (allowed && !allowed.has(req.tool))
317
+ return false;
318
+ return options.requestPermission(req);
319
+ };
320
+ const subMessages = [
321
+ { role: 'user', content: [{ type: 'text', text: prompt }] },
322
+ ];
323
+ try {
324
+ const result = await runAgentLoop(subMessages, {
325
+ ...options,
326
+ _depth: depth + 1,
327
+ _agentScope: `sub_${++_subTaskCounter}`, // isolated todo store per sub-agent
328
+ editorContext: null, // fresh isolated context for sub-agent
329
+ model: agent?.model ?? options.model,
330
+ nexrallMd: subNexrallMd,
331
+ requestPermission: gatedPermission,
332
+ onText: () => { }, // sub-agent text is returned as the tool result, not streamed live
333
+ // Forward tool events with isSubTask=true so the UI can render a badge
334
+ // instead of prepending "[sub-task]" to the tool name (which caused double-prefix
335
+ // when the name was already labelled, and mixed display concerns into the data layer).
336
+ onToolUse: (n, i) => options.onToolUse(n, i, true),
337
+ onToolResult: (n, r) => options.onToolResult(n, r, true),
338
+ // Forward thinking so the UI shows the indicator while sub-agent reasons
339
+ onThinking: (text) => options.onThinking?.(text),
340
+ onThinkingDelta: (text) => options.onThinkingDelta?.(text),
341
+ onThinkingProgress: (tok) => options.onThinkingProgress?.(tok),
342
+ });
343
+ // Extract final assistant text as the task output
344
+ const lastAssistant = [...result].reverse().find((m) => m.role === 'assistant');
345
+ const text = (lastAssistant?.content ?? [])
346
+ .filter((b) => b.type === 'text')
347
+ .map((b) => b.text)
348
+ .join('')
349
+ .trim();
350
+ return { output: text || '(sub-task completed with no text output)' };
351
+ }
352
+ catch (err) {
353
+ return { error: `Sub-task failed: ${err.message}` };
354
+ }
355
+ }
356
+ // ─── Auto-compact ─────────────────────────────────────────────────────────────
357
+ //
358
+ // When the conversation's prompt size approaches the model's context window,
359
+ // summarise the older portion automatically (Claude-Code style) instead of
360
+ // letting the request fail or forcing the user to run /compact by hand.
361
+ //
362
+ // Compaction only happens at a turn boundary (top of the loop, before the next
363
+ // streamChat) and only cuts at a "safe" user message β€” one with no tool_result
364
+ // blocks β€” so tool_use/tool_result pairing is never broken.
365
+ const MODEL_CONTEXT_TOKENS = {
366
+ turbo: 200000,
367
+ pro: 200000,
368
+ ultra: 1000000,
369
+ };
370
+ const AUTO_COMPACT_THRESHOLD = 0.8; // compact when prompt > 80% of the window
371
+ const COMPACT_KEEP_MIN = 6; // always keep at least the last N messages verbatim
372
+ function resolveAutoCompact(fromOptions, rawSettings) {
373
+ if (typeof fromOptions === 'boolean')
374
+ return fromOptions;
375
+ const env = (process.env.NEXRALL_AUTO_COMPACT ?? '').toLowerCase();
376
+ if (env === '0' || env === 'false' || env === 'off')
377
+ return false;
378
+ if (env === '1' || env === 'true' || env === 'on')
379
+ return true;
380
+ const s = rawSettings?.autoCompact;
381
+ if (typeof s === 'boolean')
382
+ return s;
383
+ return true;
384
+ }
385
+ /** Find the latest index ≀ maxIdx where history can be cut safely (user msg, no tool_results). */
386
+ function findSafeCutIndex(messages, maxIdx) {
387
+ for (let i = Math.min(maxIdx, messages.length - 1); i > 0; i--) {
388
+ const m = messages[i];
389
+ if (m.role !== 'user')
390
+ continue;
391
+ const hasToolResult = m.content.some((b) => b.type === 'tool_result');
392
+ if (!hasToolResult)
393
+ return i;
394
+ }
395
+ return -1;
396
+ }
397
+ /** Render messages to a plain-text transcript for the summariser (tool noise truncated). */
398
+ function transcriptOf(messages) {
399
+ const parts = [];
400
+ for (const m of messages) {
401
+ for (const b of m.content) {
402
+ if (b.type === 'text' && b.text) {
403
+ parts.push(`${m.role.toUpperCase()}: ${b.text.slice(0, 2000)}`);
404
+ }
405
+ else if (b.type === 'tool_use') {
406
+ parts.push(`${m.role.toUpperCase()} [tool: ${b.name}]: ${JSON.stringify(b.input ?? {}).slice(0, 400)}`);
407
+ }
408
+ else if (b.type === 'tool_result') {
409
+ parts.push(`TOOL RESULT: ${String(b.content ?? '').slice(0, 600)}`);
410
+ }
411
+ }
412
+ }
413
+ return parts.join('\n');
414
+ }
415
+ /**
416
+ * Compact `messages` in place: summarise everything before a safe cut point and
417
+ * replace it with a summary preamble. Returns true if compaction happened.
418
+ */
419
+ async function autoCompactMessages(messages, options) {
420
+ const cut = findSafeCutIndex(messages, messages.length - COMPACT_KEEP_MIN);
421
+ if (cut < 2)
422
+ return false; // nothing meaningful to fold
423
+ const toSummarize = messages.slice(0, cut);
424
+ const kept = messages.slice(cut);
425
+ const summaryPrompt = `Summarize this coding-session transcript into concise bullet points the assistant needs to continue the work: ` +
426
+ `key decisions, files changed (and how), commands run, unresolved problems, and user preferences. Max 400 words.\n\n` +
427
+ transcriptOf(toSummarize);
428
+ let summary = '';
429
+ try {
430
+ const reply = await (0, client_1.streamChat)([{ role: 'user', content: [{ type: 'text', text: summaryPrompt }] }], {
431
+ model: options.model ?? 'turbo',
432
+ mode: 'ask', // summariser must not call tools; ask-mode discourages action
433
+ env: options.env,
434
+ clientType: options.clientType,
435
+ abortSignal: options.abortSignal,
436
+ }, () => { });
437
+ summary = reply.content
438
+ .filter((b) => b.type === 'text')
439
+ .map((b) => b.text ?? '')
440
+ .join('')
441
+ .trim();
442
+ }
443
+ catch {
444
+ return false; // summarisation failed β€” leave history as is, the turn may still fit
445
+ }
446
+ if (!summary)
447
+ return false;
448
+ messages.splice(0, cut, { role: 'user', content: [{ type: 'text', text: `[Auto-compacted ${toSummarize.length} earlier messages]\n\nSummary of the earlier conversation:\n${summary}` }] }, { role: 'assistant', content: [{ type: 'text', text: 'Understood β€” I have the summary of our earlier work and will continue from there.' }] });
449
+ // `kept` follows automatically since splice only replaced the head.
450
+ void kept;
451
+ return true;
452
+ }
453
+ // ─── Agent Loop ───────────────────────────────────────────────────────────────
454
+ async function runAgentLoop(initialMessages, options) {
455
+ const messages = [...initialMessages];
456
+ const model = options.model ?? 'turbo';
457
+ const hooks = loadHooks(options.workDir);
458
+ const depth = options._depth ?? 0;
459
+ const agentScope = options._agentScope ?? 'root';
460
+ // Discover custom sub-agent types. Only the top-level agent is told the
461
+ // catalogue (sub-agents can't spawn further), but every level resolves types.
462
+ const agentTypes = (0, agentTypes_1.loadAgentTypes)(options.workDir);
463
+ const agentsCatalogue = depth === 0 ? (0, agentTypes_1.summariseAgents)(agentTypes) : '';
464
+ // Optional OS-level bash sandbox (opt-in via settings.json "sandbox").
465
+ const settings = (0, rules_1.loadSettings)(options.workDir);
466
+ const sandboxCfg = (0, sandbox_1.parseSandboxConfig)(settings.raw.sandbox) ?? undefined;
467
+ // Soft iteration budget + optional auto-continue past it (see resolvers above).
468
+ const maxIterations = resolveMaxIterations(options.maxIterations, settings.raw);
469
+ const autoContinue = resolveAutoContinue(options.autoContinue, settings.raw);
470
+ const autoCompact = resolveAutoCompact(options.autoCompact, settings.raw);
471
+ const contextWindow = MODEL_CONTEXT_TOKENS[model] ?? 200000;
472
+ // Live prompt-size estimate, updated from usage events after every stream.
473
+ let lastPromptTokens = 0;
474
+ let compacting = false; // re-entrancy guard β€” compaction itself calls streamChat
475
+ // Absolute hard stop: auto-continue extends the budget in maxIterations-sized
476
+ // segments up to this ceiling; without auto-continue, the soft budget IS the cap.
477
+ const hardCap = autoContinue ? Math.max(maxIterations, MAX_ITERATIONS_CEILING) : maxIterations;
478
+ // NOTE: we deliberately do NOT call process.chdir(options.workDir) here.
479
+ // process.cwd() is global process state β€” mutating it from concurrent sub-agent
480
+ // coroutines (task tool runs multiple sub-agents via Promise.all) causes a race
481
+ // condition where one sub-agent's chdir overwrites another's, making every tool
482
+ // that falls back to process.cwd() resolve paths against the wrong directory.
483
+ // workDir is now threaded explicitly through executeTool β†’ resolvePath instead.
484
+ // Distinguish a clean finish (model produced a final answer) from hitting the
485
+ // iteration backstop, so we can tell the user what happened rather than
486
+ // silently returning a half-done task.
487
+ let completedCleanly = false;
488
+ let stalledOut = false; // tripped the runaway guard (all-failed rounds)
489
+ let consecutiveErrorRounds = 0; // rounds where every tool call errored
490
+ let budget = maxIterations; // extended by auto-continue, capped at hardCap
491
+ let iteration = 0;
492
+ try {
493
+ for (; iteration < budget; iteration++) {
494
+ if (options.abortSignal?.aborted)
495
+ break;
496
+ // Auto-compact: if the last request's prompt crossed the threshold,
497
+ // summarise older history before the next stream so we never hit the
498
+ // context-window wall mid-task. Runs at a turn boundary only.
499
+ if (autoCompact && !compacting && lastPromptTokens > contextWindow * AUTO_COMPACT_THRESHOLD && messages.length > COMPACT_KEEP_MIN + 2) {
500
+ compacting = true;
501
+ try {
502
+ const did = await autoCompactMessages(messages, options);
503
+ if (did) {
504
+ lastPromptTokens = 0; // stale β€” next usage event refreshes it
505
+ options.onText(`\n\u267b\ufe0f Auto-compacted earlier conversation to stay within the context window.\n`);
506
+ }
507
+ }
508
+ finally {
509
+ compacting = false;
510
+ }
511
+ }
512
+ // When auto-continue is off, nudge the model to wrap up just before the hard
513
+ // cap so it isn't cut off mid-task. (With auto-continue on we WANT it to keep
514
+ // going, so no wrap-up nudge.)
515
+ if (!autoContinue && iteration === budget - 5 && budget > 5) {
516
+ options.onText(`\n⚠️ Approaching the ${budget}-step limit (step ${iteration + 1}). Please wrap up and summarise what has been done.\n`);
517
+ }
518
+ // Collect tool_use blocks emitted during this stream
519
+ const pendingToolUse = [];
520
+ // Build SSE event handler
521
+ const onEvent = (event) => {
522
+ switch (event.type) {
523
+ case 'text':
524
+ options.onText(event.text);
525
+ break;
526
+ case 'thinking':
527
+ options.onThinking?.(event.text);
528
+ break;
529
+ case 'thinking_delta':
530
+ options.onThinkingDelta?.(event.text);
531
+ break;
532
+ case 'thinking_progress':
533
+ options.onThinkingProgress?.(event.tokens);
534
+ break;
535
+ case 'tool_use':
536
+ // Collect tool use blocks β€” they may arrive incrementally
537
+ // We use message_complete to finalize them, so just track here
538
+ pendingToolUse.push({
539
+ type: 'tool_use',
540
+ id: event.id,
541
+ name: event.name,
542
+ input: event.input,
543
+ });
544
+ break;
545
+ case 'usage':
546
+ lastPromptTokens =
547
+ (event.usage.input_tokens ?? 0) +
548
+ (event.usage.cache_creation_input_tokens ?? 0) +
549
+ (event.usage.cache_read_input_tokens ?? 0);
550
+ options.onUsage(event.usage);
551
+ break;
552
+ case 'message_complete':
553
+ // The complete message is returned from streamChat, we handle it below
554
+ break;
555
+ case 'done':
556
+ case 'error':
557
+ break;
558
+ }
559
+ };
560
+ // 1. Stream from the API
561
+ let assistantMessage;
562
+ try {
563
+ assistantMessage = await (0, client_1.streamChat)(messages, {
564
+ model,
565
+ mode: options.mode,
566
+ effort: options.effort,
567
+ env: options.env,
568
+ editorContext: options.editorContext,
569
+ nexrallMd: options.nexrallMd,
570
+ clientType: options.clientType,
571
+ abortSignal: options.abortSignal,
572
+ extraTools: options.mcpManager?.getAnthropicTools(),
573
+ agents: agentsCatalogue || undefined,
574
+ }, onEvent);
575
+ }
576
+ catch (err) {
577
+ if (options.abortSignal?.aborted || err.name === 'AbortError')
578
+ break;
579
+ runSimpleHooks(hooks.OnError, options.workDir);
580
+ throw new Error(`Stream failed: ${err.message}`);
581
+ }
582
+ if (options.abortSignal?.aborted)
583
+ break;
584
+ // 2. Strip thinking blocks β€” API rejects them in message history
585
+ assistantMessage.content = assistantMessage.content.filter((b) => b.type !== 'thinking' && b.type !== 'redacted_thinking');
586
+ // If the model returned nothing usable (e.g. thinking-only, then stopped), the
587
+ // stripped content is empty. Do NOT push an empty assistant turn into history β€” it
588
+ // would be sent back on the next request and either error or force a placeholder,
589
+ // and combined with a folded follow-up it leaks an empty turn to the model. Treat
590
+ // it as a no-op step: fold any queued input and continue, otherwise end cleanly.
591
+ if (assistantMessage.content.length === 0) {
592
+ const queued = options.takePendingInput?.() ?? [];
593
+ if (queued.length) {
594
+ const text = queued.join('\n\n');
595
+ queued.forEach((q) => options.onInjectedInput?.(q));
596
+ messages.push({ role: 'user', content: [{ type: 'text', text }] });
597
+ continue;
598
+ }
599
+ runSimpleHooks(hooks.PostMessageComplete, options.workDir);
600
+ completedCleanly = true;
601
+ break;
602
+ }
603
+ messages.push(assistantMessage);
604
+ // 3. Collect tool_use blocks from the completed message.
605
+ // Server-side tools (e.g. web_search_20260209) are executed by Anthropic and their
606
+ // tool_result blocks appear in the SAME assistant message. We must skip those β€”
607
+ // sending a second tool_result for them would confuse the API.
608
+ const serverSideResultIds = new Set(assistantMessage.content
609
+ .filter(b => b.type === 'tool_result')
610
+ .map(b => b.tool_use_id)
611
+ .filter((id) => !!id));
612
+ const toolUseBlocks = assistantMessage.content.filter((block) => block.type === 'tool_use' && !serverSideResultIds.has(block.id));
613
+ // 4. If no tool use β†’ agent produced a final text response.
614
+ // But if the user queued a follow-up while we were working, fold it in as the
615
+ // next user turn and keep going instead of ending (Claude-Code style).
616
+ if (toolUseBlocks.length === 0) {
617
+ const queued = options.takePendingInput?.() ?? [];
618
+ if (queued.length) {
619
+ const text = queued.join('\n\n');
620
+ queued.forEach((q) => options.onInjectedInput?.(q));
621
+ messages.push({ role: 'user', content: [{ type: 'text', text }] });
622
+ continue;
623
+ }
624
+ runSimpleHooks(hooks.PostMessageComplete, options.workDir);
625
+ completedCleanly = true;
626
+ break;
627
+ }
628
+ // 5. Execute all tool uses in parallel
629
+ const toolResults = await Promise.all(toolUseBlocks.map(async (block) => {
630
+ const { id, name, input } = block;
631
+ // Notify caller about pending tool use
632
+ options.onToolUse(name, input);
633
+ // Request permission
634
+ const description = humanDescription(name, input);
635
+ let result;
636
+ let permitted;
637
+ try {
638
+ permitted = await options.requestPermission({ tool: name, input, description });
639
+ }
640
+ catch {
641
+ permitted = false;
642
+ }
643
+ if (!permitted) {
644
+ result = { error: 'Permission denied by user' };
645
+ }
646
+ else if (name === 'task') {
647
+ result = await runSubTask(input, options, agentTypes);
648
+ }
649
+ else {
650
+ const pre = runToolHooks(hooks.PreToolUse, 'PreToolUse', name, input, options.workDir);
651
+ if (pre.block) {
652
+ result = { error: `Blocked by PreToolUse hook: ${pre.reason}` };
653
+ }
654
+ else {
655
+ try {
656
+ // 1. Try platform-specific tools (e.g. VS Code semantic tools)
657
+ const external = options.executeExternalTool
658
+ ? await options.executeExternalTool(name, input)
659
+ : null;
660
+ if (external !== null && external !== undefined) {
661
+ result = external;
662
+ // 2. Try MCP tools (serverName__toolName)
663
+ }
664
+ else if (options.mcpManager?.isMcpTool(name)) {
665
+ const mcpOutput = await options.mcpManager.callTool(name, input);
666
+ result = { output: mcpOutput ?? '' };
667
+ // 3. Fall through to built-in executor
668
+ }
669
+ else {
670
+ // Snapshot pre-mutation state so the user can /rewind this turn.
671
+ options.checkpointManager?.recordBeforeMutation(name, input);
672
+ // Serialise writes to the same file: if the model emits two
673
+ // edit_file/write_file calls for the same path in one turn
674
+ // (executed via Promise.all), the second would overwrite the
675
+ // first. withFileLock queues them so they apply sequentially.
676
+ const WRITE_TOOLS = new Set(['write_file', 'edit_file', 'multi_edit', 'delete_file', 'move_file', 'copy_file', 'notebook_edit']);
677
+ if (WRITE_TOOLS.has(name)) {
678
+ const targetPath = (typeof input.path === 'string' ? input.path : typeof input.source === 'string' ? input.source : '');
679
+ const absTarget = targetPath && options.workDir ? path.resolve(options.workDir, targetPath) : targetPath;
680
+ result = await withFileLock(absTarget || name, () => (0, executor_1.executeTool)(name, input, options.abortSignal, sandboxCfg, options.workDir, agentScope));
681
+ }
682
+ else {
683
+ result = await (0, executor_1.executeTool)(name, input, options.abortSignal, sandboxCfg, options.workDir, agentScope);
684
+ }
685
+ }
686
+ }
687
+ catch (err) {
688
+ result = { error: `Tool execution failed: ${err.message}` };
689
+ }
690
+ // PostToolUse can inject context for the model or flag a problem.
691
+ const post = runToolHooks(hooks.PostToolUse, 'PostToolUse', name, input, options.workDir, result);
692
+ const injected = [pre.context, post.context].filter(Boolean).join('\n');
693
+ if (injected) {
694
+ if (result.error !== undefined)
695
+ result.error = `${result.error}\n\n[hook] ${injected}`;
696
+ else
697
+ result.output = `${result.output ?? ''}\n\n[hook] ${injected}`;
698
+ }
699
+ if (post.block) {
700
+ // e.g. a lint/test hook failed β€” surface it as an error so the model fixes it.
701
+ result.error = `${result.error ? result.error + '\n' : ''}PostToolUse hook flagged: ${post.reason}`;
702
+ }
703
+ }
704
+ }
705
+ // Notify caller about result
706
+ options.onToolResult(name, result);
707
+ return { block: { ...block, id }, result };
708
+ }));
709
+ // 6. Build tool_result message and append to history
710
+ const toolResultBlocks = toolResults.map(({ block, result }) => ({
711
+ type: 'tool_result',
712
+ tool_use_id: block.id,
713
+ content: result.error
714
+ ? `Error: ${result.error}`
715
+ : result.output ?? '',
716
+ is_error: result.error !== undefined,
717
+ }));
718
+ // Fold any follow-up the user queued mid-task into THIS user turn (alongside the
719
+ // tool results) so the agent sees it on the very next step β€” not after the whole
720
+ // task finishes. Appending a text block to the tool_result user message keeps the
721
+ // required user/assistant alternation intact.
722
+ const toolResultContent = [...toolResultBlocks];
723
+ const queuedMid = options.takePendingInput?.() ?? [];
724
+ if (queuedMid.length) {
725
+ const text = queuedMid.join('\n\n');
726
+ queuedMid.forEach((q) => options.onInjectedInput?.(q));
727
+ toolResultContent.push({ type: 'text', text });
728
+ }
729
+ const toolResultMessage = {
730
+ role: 'user',
731
+ content: toolResultContent,
732
+ };
733
+ messages.push(toolResultMessage);
734
+ // Runaway guard: if every tool call in this round failed, count it. Enough
735
+ // consecutive all-failed rounds (e.g. a command that always errors, or the
736
+ // user denying every permission) means we're stuck β€” stop instead of
737
+ // burning the whole budget spinning.
738
+ const allErrored = toolResults.length > 0 && toolResults.every(({ result }) => result.error !== undefined);
739
+ consecutiveErrorRounds = allErrored ? consecutiveErrorRounds + 1 : 0;
740
+ if (consecutiveErrorRounds >= STALL_LIMIT) {
741
+ stalledOut = true;
742
+ break;
743
+ }
744
+ // Auto-continue: about to exhaust the current budget but the model is still
745
+ // calling tools (task unfinished) and we're under the ceiling β†’ extend the
746
+ // budget by another segment and keep going, so the agent finishes on its own
747
+ // without the user having to type "continue". History stays valid (ends on a
748
+ // tool_result turn), so no API error on the next step.
749
+ if (autoContinue && iteration + 1 >= budget && budget < hardCap) {
750
+ budget = Math.min(budget + maxIterations, hardCap);
751
+ options.onText(`\nπŸ”„ Auto-continuing (extended to ${budget} steps) β€” the task isn't finished yet.\n`);
752
+ }
753
+ // Loop back to step 1
754
+ }
755
+ // Explain why we stopped when it wasn't a clean finish or a user abort, so a
756
+ // long run never just goes silent. History ends on a tool_result turn, so
757
+ // "continue" resumes exactly where it left off.
758
+ if (!options.abortSignal?.aborted && !completedCleanly) {
759
+ if (stalledOut) {
760
+ options.onText(`\nπŸ›‘ Stopped: the last ${STALL_LIMIT} tool rounds all failed, so the agent looked stuck. ` +
761
+ `Fix the underlying error (or grant the needed permission) and send "continue".\n`);
762
+ }
763
+ else if (iteration >= budget) {
764
+ options.onText(`\n⏸️ Stopped at the ${budget}-step safety limit β€” the task may be incomplete. ` +
765
+ `Send "continue" to resume, or raise the limit via "maxIterations" in .nexrall/settings.json ` +
766
+ `(or the NEXRALL_MAX_ITERATIONS env var). Auto-continue can be disabled with "autoContinue": false.\n`);
767
+ }
768
+ }
769
+ }
770
+ finally {
771
+ runSimpleHooks(hooks.OnStop, options.workDir);
772
+ }
773
+ return messages;
774
+ }
775
+ //# sourceMappingURL=loop.js.map