@hncode/hncode 0.5.1 → 0.5.4
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/bin/install.cjs +24 -44
- package/bin/preinstall.cjs +0 -14
- package/package.json +2 -2
- package/src/agent-task.js +12 -1
- package/src/agent.js +279 -48
- package/src/cache.js +175 -130
- package/src/cell-buffer.js +5 -0
- package/src/clipboard.js +111 -8
- package/src/context-bar.js +46 -5
- package/src/doctor.js +7 -2
- package/src/git.js +35 -4
- package/src/index.js +29 -5
- package/src/keybindings.js +25 -3
- package/src/llm.js +60 -6
- package/src/mcp.js +419 -384
- package/src/permissions.js +29 -7
- package/src/plugin.js +95 -10
- package/src/registry-browser.js +21 -6
- package/src/remote.js +307 -289
- package/src/session-core.js +203 -0
- package/src/session-host.js +213 -0
- package/src/session-view.js +110 -0
- package/src/settings-schema.js +12 -9
- package/src/shell-safety.js +610 -473
- package/src/swarm-progress.js +348 -293
- package/src/tasks-browser.js +4 -1
- package/src/team/constants.js +20 -0
- package/src/team/context.js +89 -0
- package/src/team/mailbox.js +216 -0
- package/src/team/permission.js +121 -0
- package/src/team/protocol.js +167 -0
- package/src/team/runner.js +462 -0
- package/src/team/spawn.js +305 -0
- package/src/team/store.js +172 -0
- package/src/team/tasks.js +177 -0
- package/src/team/team.js +346 -0
- package/src/team-panel.js +624 -0
- package/src/term-caps.js +457 -410
- package/src/term.js +162 -19
- package/src/tool-result.js +70 -7
- package/src/tools/agent-swarm.js +114 -11
- package/src/tools/agent.js +464 -386
- package/src/tools/bash.js +53 -14
- package/src/tools/diagnostics.js +13 -0
- package/src/tools/edit.js +428 -50
- package/src/tools/git.js +15 -3
- package/src/tools/glob.js +5 -3
- package/src/tools/grep.js +19 -5
- package/src/tools/ignore.js +17 -3
- package/src/tools/index.js +19 -3
- package/src/tools/read.js +2 -1
- package/src/tools/shell.js +145 -134
- package/src/tools/tasks.js +214 -141
- package/src/tools/team.js +411 -0
- package/src/tools/utils.js +12 -3
- package/src/tools/web-search.js +53 -40
- package/src/tui.js +7496 -5366
- package/src/update.js +320 -115
- package/src/web-daemon.js +27 -4
- package/src/web-live.js +250 -0
package/src/git.js
CHANGED
|
@@ -33,6 +33,10 @@ const GIT_ENV = [
|
|
|
33
33
|
|
|
34
34
|
// Run git with args in `cwd`. Returns { ok, stdout, stderr, code, error }.
|
|
35
35
|
// Never throws: a missing git binary or a non-repo is data, not an exception.
|
|
36
|
+
//
|
|
37
|
+
// SYNCHRONOUS, and therefore only for the queries made at STARTUP (the status bar's branch and
|
|
38
|
+
// sha). It blocks the event loop, so an AbortSignal cannot fire during it — use `runGitAsync` for
|
|
39
|
+
// anything a tool call reaches.
|
|
36
40
|
export function runGit(args, cwd, opts = {}) {
|
|
37
41
|
const timeout = opts.timeoutMs || 30_000;
|
|
38
42
|
try {
|
|
@@ -53,7 +57,6 @@ export function runGit(args, cwd, opts = {}) {
|
|
|
53
57
|
return { ok: false, stdout: '', stderr: '', code: null, error: e.message };
|
|
54
58
|
}
|
|
55
59
|
}
|
|
56
|
-
|
|
57
60
|
export function isRepo(cwd) {
|
|
58
61
|
const r = runGit(['rev-parse', '--is-inside-work-tree'], cwd);
|
|
59
62
|
return r.ok && r.stdout.trim() === 'true';
|
|
@@ -436,10 +439,20 @@ export function rebase(cwd, onto) {
|
|
|
436
439
|
// The async form below runs the same command off the main thread, so the frame
|
|
437
440
|
// draws immediately and the badge fills in when the data arrives.
|
|
438
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Run git without blocking the event loop.
|
|
444
|
+
*
|
|
445
|
+
* `opts.signal` makes the command interruptible, which is the whole reason a tool call must use this
|
|
446
|
+
* rather than `runGit`: a synchronous `spawnSync` holds the event loop, so an AbortSignal can never
|
|
447
|
+
* fire during it — measured, `spawnSync` accepts a `signal` option and ignores it, waiting out the
|
|
448
|
+
* full timeout. With Esc interrupting a turn, `git status` or `git log` on a large repository would
|
|
449
|
+
* otherwise freeze the TUI for up to the timeout with no key able to reach it.
|
|
450
|
+
*/
|
|
439
451
|
export function runGitAsync(args, cwd, opts = {}) {
|
|
440
452
|
return new Promise((resolve) => {
|
|
441
453
|
let child;
|
|
442
454
|
const timeout = opts.timeoutMs || 30_000;
|
|
455
|
+
const signal = opts.signal;
|
|
443
456
|
try {
|
|
444
457
|
child = cp.spawn('git', [...GIT_ENV, ...args], {
|
|
445
458
|
cwd: cwd || process.cwd(),
|
|
@@ -453,11 +466,29 @@ export function runGitAsync(args, cwd, opts = {}) {
|
|
|
453
466
|
let stdout = '';
|
|
454
467
|
let stderr = '';
|
|
455
468
|
let done = false;
|
|
456
|
-
const finish = (r) => {
|
|
469
|
+
const finish = (r) => {
|
|
470
|
+
if (done) return;
|
|
471
|
+
done = true;
|
|
472
|
+
clearTimeout(timer);
|
|
473
|
+
if (signal) signal.removeEventListener('abort', onAbort);
|
|
474
|
+
resolve(r);
|
|
475
|
+
};
|
|
457
476
|
const timer = setTimeout(() => {
|
|
458
|
-
try { child.kill(); } catch {}
|
|
459
|
-
|
|
477
|
+
try { child.kill(); } catch { /* already gone */ }
|
|
478
|
+
// The partial output is returned rather than dropped: it is often still useful, and an empty
|
|
479
|
+
// string would look like "git had nothing to say" instead of "git was cut short".
|
|
480
|
+
finish({ ok: false, stdout, stderr, code: null, error: 'git timed out' });
|
|
460
481
|
}, timeout);
|
|
482
|
+
// `interrupted` is a distinct error from a timeout, so the caller can tell a user's Esc from a
|
|
483
|
+
// command that hung.
|
|
484
|
+
const onAbort = () => {
|
|
485
|
+
try { child.kill(); } catch { /* already gone */ }
|
|
486
|
+
finish({ ok: false, stdout, stderr, code: null, error: 'interrupted' });
|
|
487
|
+
};
|
|
488
|
+
if (signal) {
|
|
489
|
+
if (signal.aborted) { clearTimeout(timer); onAbort(); return; }
|
|
490
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
491
|
+
}
|
|
461
492
|
if (child.stdout) child.stdout.on('data', (d) => { stdout += d.toString('utf8'); });
|
|
462
493
|
if (child.stderr) child.stderr.on('data', (d) => { stderr += d.toString('utf8'); });
|
|
463
494
|
child.on('error', (e) => finish({ ok: false, stdout: '', stderr: '', code: null, error: e.message }));
|
package/src/index.js
CHANGED
|
@@ -5,9 +5,8 @@ import path from 'node:path';
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import { resolveConfig, effectiveSnapshot, saveConfig, resolveModelArg, readPersonalPrompt } from './config.js';
|
|
7
7
|
import * as sess from './session.js';
|
|
8
|
-
import { Agent, SYSTEM_PROMPT } from './agent.js';
|
|
8
|
+
import { Agent, SYSTEM_PROMPT, canSplitAfter } from './agent.js';
|
|
9
9
|
import { llmTools } from './tools/index.js';
|
|
10
|
-
import { setToolsList } from './llm.js';
|
|
11
10
|
import { localVersion } from './version.js';
|
|
12
11
|
import { EXIT, FORMATS, isValidFormat, buildResult, exitCodeFor, resultLine, createRunTracker, trackEvent, finalReply } from './ci.js';
|
|
13
12
|
|
|
@@ -301,13 +300,31 @@ async function runPrompt({ cfg, prompt, format, session, modelArg, timeoutSecond
|
|
|
301
300
|
return EXIT.CONFIG;
|
|
302
301
|
}
|
|
303
302
|
const messages = [];
|
|
304
|
-
if (session && session.messages) for (const m of session.messages.slice(-30)) messages.push(m);
|
|
305
303
|
// Same layering as the TUI: built-in prompt first, then the user's own
|
|
306
304
|
// system_prompt override if any, then the /personal notes, then calm mode.
|
|
307
305
|
let sysText = (cfg.systemPrompt && String(cfg.systemPrompt).trim()) || SYSTEM_PROMPT;
|
|
308
306
|
const personal = readPersonalPrompt(cfg.workspace);
|
|
309
307
|
if (personal) sysText += '\n\n' + personal;
|
|
310
308
|
messages.push({ role: 'system', content: sysText });
|
|
309
|
+
if (session && session.messages) {
|
|
310
|
+
// Resume on a TOOL-EXCHANGE BOUNDARY: a cut that separates an assistant
|
|
311
|
+
// message from its tool results (or leaves orphaned tool results) is a 400
|
|
312
|
+
// from the provider, not just a shorter history. `canSplitAfter` is the same
|
|
313
|
+
// rule the compaction logic uses. System messages are dropped — the fresh
|
|
314
|
+
// one above is authoritative.
|
|
315
|
+
const hist = session.messages.filter((m) => m && m.role !== 'system');
|
|
316
|
+
// A session persisted mid-step can end with an assistant message whose tool
|
|
317
|
+
// results were never appended; sending it before the user message is a 400.
|
|
318
|
+
while (hist.length && hist[hist.length - 1].role === 'assistant'
|
|
319
|
+
&& Array.isArray(hist[hist.length - 1].toolCalls) && hist[hist.length - 1].toolCalls.length) hist.pop();
|
|
320
|
+
let start = Math.max(0, hist.length - 30);
|
|
321
|
+
while (start > 0 && !canSplitAfter(hist, start - 1)) start++;
|
|
322
|
+
for (let i = start; i < hist.length; i++) messages.push(hist[i]);
|
|
323
|
+
}
|
|
324
|
+
// THE PROMPT ITSELF. This used to never be pushed: -p sent only the resumed
|
|
325
|
+
// history and the system message, so a fresh session answered nothing the
|
|
326
|
+
// user asked and a resumed one answered nothing new.
|
|
327
|
+
messages.push({ role: 'user', content: prompt });
|
|
311
328
|
const saved = session || { id: sess.newId(), title: prompt.slice(0, 60), workspace: path.resolve(process.cwd()), model: cfg.model, createdAt: Date.now(), messages: [] };
|
|
312
329
|
const tracker = createRunTracker();
|
|
313
330
|
const startedAt = Date.now();
|
|
@@ -355,6 +372,13 @@ async function runPrompt({ cfg, prompt, format, session, modelArg, timeoutSecond
|
|
|
355
372
|
await agent.run();
|
|
356
373
|
} finally {
|
|
357
374
|
if (timer) clearTimeout(timer);
|
|
375
|
+
// MCP stdio children were spawned for this run; most die on stdin EOF at
|
|
376
|
+
// process exit, but one that ignores EOF would linger. Kill them outright —
|
|
377
|
+
// best-effort, the result has already been produced.
|
|
378
|
+
try {
|
|
379
|
+
const mcp = await import('./mcp.js');
|
|
380
|
+
mcp.disconnectAll(mcp.getLiveConnections());
|
|
381
|
+
} catch { /* best-effort */ }
|
|
358
382
|
}
|
|
359
383
|
sess.saveSession(saved);
|
|
360
384
|
|
|
@@ -505,14 +529,14 @@ export async function main(argv) {
|
|
|
505
529
|
// starts this in the background and reports the result as a notice.
|
|
506
530
|
// Failures are reported but never fatal: an unreachable server must not stop
|
|
507
531
|
// the session from starting.
|
|
508
|
-
const connectMcp = async () => {
|
|
532
|
+
const connectMcp = async (mcpOpts = {}) => {
|
|
509
533
|
if (cfg.mcp === false) return { servers: [], toolCount: 0 };
|
|
510
534
|
try {
|
|
511
535
|
const mcp = await import('./mcp.js');
|
|
512
536
|
const { API } = await import('./plugin.js');
|
|
513
537
|
const mcpCfg = mcp.loadMcpConfig(cfg.workspace);
|
|
514
538
|
if (!Object.keys(mcpCfg.servers).length) return { servers: [], toolCount: 0 };
|
|
515
|
-
const res = await mcp.connectAll(mcpCfg, (spec) => API.registerTool(spec));
|
|
539
|
+
const res = await mcp.connectAll(mcpCfg, (spec) => API.registerTool(spec), mcpOpts);
|
|
516
540
|
mcp.setLiveConnections(res.servers);
|
|
517
541
|
if (process.env.HNCODE_MCP_VERBOSE === '1') {
|
|
518
542
|
for (const s of res.servers) {
|
package/src/keybindings.js
CHANGED
|
@@ -110,13 +110,35 @@ const isCommand = (s) => typeof s === 'string' && s.trim().startsWith('/');
|
|
|
110
110
|
* Read and validate the bindings file.
|
|
111
111
|
|
|
112
112
|
/**
|
|
113
|
-
* Read and validate the bindings file.
|
|
113
|
+
* Read and validate the bindings file, cached by mtime+size.
|
|
114
114
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
115
|
+
* The TUI resolves EVERY special keystroke through this, and a readFileSync per key
|
|
116
|
+
* (an ENOENT open is still a syscall; with AV scanning on Windows it is milliseconds)
|
|
117
|
+
* sat directly in the input path. The cache returns the parsed result unchanged while
|
|
118
|
+
* the file's stat is unchanged, so an edit still takes effect immediately — the stat
|
|
119
|
+
* is the only thing a keystroke pays for.
|
|
117
120
|
*/
|
|
121
|
+
let _kbCache = null; // { file, mtimeMs, size, out }
|
|
118
122
|
export function loadKeybindings(home) {
|
|
119
123
|
const file = keybindingsFile(home);
|
|
124
|
+
try {
|
|
125
|
+
const st = fs.statSync(file);
|
|
126
|
+
if (_kbCache && _kbCache.file === file && _kbCache.mtimeMs === st.mtimeMs && _kbCache.size === st.size) {
|
|
127
|
+
return _kbCache.out;
|
|
128
|
+
}
|
|
129
|
+
const out = readKeybindingsFile(file);
|
|
130
|
+
_kbCache = { file, mtimeMs: st.mtimeMs, size: st.size, out };
|
|
131
|
+
return out;
|
|
132
|
+
} catch {
|
|
133
|
+
// Absent/unstatable: parse anyway so the error reporting below still runs,
|
|
134
|
+
// and drop the cache — the file may have just been deleted.
|
|
135
|
+
_kbCache = null;
|
|
136
|
+
return readKeybindingsFile(file);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The uncached parse (the body of the old loadKeybindings). */
|
|
141
|
+
function readKeybindingsFile(file) {
|
|
120
142
|
const out = { bindings: new Map(), errors: [], file };
|
|
121
143
|
let raw;
|
|
122
144
|
try { raw = fs.readFileSync(file, 'utf8'); }
|
package/src/llm.js
CHANGED
|
@@ -93,7 +93,7 @@ export function toOpenAi(messages) {
|
|
|
93
93
|
} else if (m.role === 'assistant') {
|
|
94
94
|
const hasText = typeof m.content === 'string' && m.content.trim();
|
|
95
95
|
const hasCalls = Array.isArray(m.toolCalls) && m.toolCalls.length;
|
|
96
|
-
|
|
96
|
+
if (!hasText && !hasCalls) continue; // the provider rejects an empty assistant turn; drop it
|
|
97
97
|
const msg = { role: 'assistant', content: m.content || null };
|
|
98
98
|
if (hasCalls) {
|
|
99
99
|
msg.tool_calls = m.toolCalls.map((tc) => ({
|
|
@@ -296,7 +296,9 @@ function buildBody(cfg, messages, streaming = true, opts = {}) {
|
|
|
296
296
|
model: cfg.innerModel, messages: toOpenAi(messages),
|
|
297
297
|
...common, ...wire,
|
|
298
298
|
};
|
|
299
|
-
|
|
299
|
+
// Only attach a NON-EMPTY tools array: `tools: []` is rejected by some strict
|
|
300
|
+
// gateways, and an instance that registered no list means "no tools here".
|
|
301
|
+
if (!opts.noTools && defs.length) body.tools = defs;
|
|
300
302
|
return JSON.stringify(applyPromptCache('openai', body, {
|
|
301
303
|
enabled: cfg.promptCache !== false,
|
|
302
304
|
sessionKey: cfg.sessionId,
|
|
@@ -313,6 +315,12 @@ export class LLM {
|
|
|
313
315
|
constructor(cfg) {
|
|
314
316
|
this.cfg = cfg;
|
|
315
317
|
this.controller = null;
|
|
318
|
+
// The tool defs THIS instance sends. Per-instance, NOT module-global: the
|
|
319
|
+
// global (`llmToolsList` below) was overwritten by every `new Agent()` in
|
|
320
|
+
// the process, so a subagent, swarm member or teammate constructed while a
|
|
321
|
+
// parent request was building its body silently swapped the parent's tool
|
|
322
|
+
// list mid-turn. The agent sets this via setTools() right after construction.
|
|
323
|
+
this._tools = null;
|
|
316
324
|
// Inline-reasoning state carried ACROSS requests. Our system prompt tells the
|
|
317
325
|
// model to put all its reasoning inside one <think> span, and tool calls happen
|
|
318
326
|
// inside that span — so the span outlives a single request. Reset when the turn
|
|
@@ -321,6 +329,11 @@ export class LLM {
|
|
|
321
329
|
this._tagPending = '';
|
|
322
330
|
}
|
|
323
331
|
|
|
332
|
+
// Register the tool list this instance advertises (see `_tools`). Only ever
|
|
333
|
+
// store an array or null: a non-array would make every later request throw
|
|
334
|
+
// when the defs are built.
|
|
335
|
+
setTools(list) { this._tools = Array.isArray(list) ? list : null; }
|
|
336
|
+
|
|
324
337
|
// Called by the agent at the end of a turn: the next turn starts a fresh
|
|
325
338
|
// reasoning block, even if the previous one never saw its closing tag.
|
|
326
339
|
resetThink() {
|
|
@@ -341,13 +354,29 @@ export class LLM {
|
|
|
341
354
|
// a capped budget plus a reasoning model is what silently produced an empty
|
|
342
355
|
// title (see buildBody), and an internal request has no tools to call.
|
|
343
356
|
async requestText(messages, opts = {}) {
|
|
357
|
+
// One silent retry on FAILURE (null). These one-shot requests (title,
|
|
358
|
+
// compaction summary) have no outer retry loop, and a single transient
|
|
359
|
+
// 429/network blip used to come back as null — which the compaction path
|
|
360
|
+
// treated as "no summary" and trimmed the history over. `''` (capped-empty)
|
|
361
|
+
// is a meaningful verdict for the caller and is NOT retried here.
|
|
362
|
+
let out = await this._requestTextOnce(messages, opts);
|
|
363
|
+
if (out === null && !(this.controller && this.controller.signal.aborted)) {
|
|
364
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
365
|
+
out = await this._requestTextOnce(messages, opts);
|
|
366
|
+
}
|
|
367
|
+
return out;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async _requestTextOnce(messages, opts = {}) {
|
|
344
371
|
const cfg = this.cfg;
|
|
345
372
|
this.controller = new AbortController();
|
|
373
|
+
// This instance's tools, unless the caller opted out (internal one-shots).
|
|
374
|
+
const bodyOpts = { ...opts, ...((this._tools && !opts.noTools) ? { tools: this._tools } : {}) };
|
|
346
375
|
try {
|
|
347
376
|
const res = await fetch(cfg.endpoint, {
|
|
348
377
|
method: 'POST',
|
|
349
378
|
headers: (() => { const h = authHeaders(cfg); try { const out = runPatchSync('llmHeaders', { headers: h }, (c) => c); if (out && out.headers) return out.headers; } catch (e) {} return h; })(),
|
|
350
|
-
body: (() => { let b = buildBody(cfg, messages, false,
|
|
379
|
+
body: (() => { let b = buildBody(cfg, messages, false, bodyOpts); try { const out = runPatchSync('llmBody', { body: b, cfg }, (c) => c); if (out && out.body !== undefined) b = out.body; } catch (e) {} return b; })(),
|
|
351
380
|
signal: this.controller.signal,
|
|
352
381
|
});
|
|
353
382
|
if (!res || !res.ok) {
|
|
@@ -397,7 +426,7 @@ export class LLM {
|
|
|
397
426
|
}
|
|
398
427
|
}
|
|
399
428
|
|
|
400
|
-
async request(messages, onEvent) {
|
|
429
|
+
async request(messages, onEvent, cacheEnd) {
|
|
401
430
|
// Mixin seam: let plugins mutate the messages sent to the model.
|
|
402
431
|
try { const out = await runPatch('llmRequest', { messages, cfg: this.cfg }, (c) => c); if (out && out.messages) messages = out.messages; } catch (e) { /* patch failed; proceed with original */ }
|
|
403
432
|
const cfg = this.cfg;
|
|
@@ -418,9 +447,20 @@ export class LLM {
|
|
|
418
447
|
// turns it off and the retry goes out without it — degrading to an estimate
|
|
419
448
|
// rather than failing the request over an accounting nicety.
|
|
420
449
|
const reqOpts = {};
|
|
450
|
+
// This instance's tools ride along on every attempt (per-instance; see the
|
|
451
|
+
// constructor note on `_tools`). `tools` is only included when set, so
|
|
452
|
+
// buildBody's hasOwnProperty probe falls through to the global for callers
|
|
453
|
+
// that never registered a list.
|
|
454
|
+
const instanceTools = this._tools ? { tools: this._tools } : {};
|
|
421
455
|
|
|
422
456
|
for (let attempt = 1; attempt <= RETRIES; attempt++) {
|
|
423
457
|
attempts = attempt;
|
|
458
|
+
// Reset per attempt. Left holding the PREVIOUS attempt's response, a network error on
|
|
459
|
+
// this attempt left `res` truthy and `!res.ok` true, so the loop reported the old
|
|
460
|
+
// status and the old body — `LLM request failed (503)` for a connection that was
|
|
461
|
+
// actually refused, with `lastError` (the real cause) never reaching the user.
|
|
462
|
+
res = null;
|
|
463
|
+
lastError = null;
|
|
424
464
|
// Fresh controller per attempt so an abort aborts this specific request
|
|
425
465
|
// and the retry loop stops.
|
|
426
466
|
this.controller = new AbortController();
|
|
@@ -429,7 +469,7 @@ export class LLM {
|
|
|
429
469
|
res = await fetch(cfg.endpoint, {
|
|
430
470
|
method: 'POST',
|
|
431
471
|
headers: (() => { const h = authHeaders(cfg); try { const out = runPatchSync('llmHeaders', { headers: h }, (c) => c); if (out && out.headers) return out.headers; } catch (e) {} return h; })(),
|
|
432
|
-
body: (() => { let b = buildBody(cfg, messages, true, reqOpts); try { const out = runPatchSync('llmBody', { body: b, cfg }, (c) => c); if (out && out.body !== undefined) b = out.body; } catch (e) {} return b; })(),
|
|
472
|
+
body: (() => { let b = buildBody(cfg, messages, true, { ...reqOpts, cacheEnd, ...instanceTools }); try { const out = runPatchSync('llmBody', { body: b, cfg }, (c) => c); if (out && out.body !== undefined) b = out.body; } catch (e) {} return b; })(),
|
|
433
473
|
signal,
|
|
434
474
|
});
|
|
435
475
|
} catch (e) {
|
|
@@ -446,6 +486,10 @@ export class LLM {
|
|
|
446
486
|
if (res.status === 400 && reqOpts.includeUsage !== false) reqOpts.includeUsage = false;
|
|
447
487
|
}
|
|
448
488
|
if (attempt < RETRIES) {
|
|
489
|
+
// Release the FAILED attempt's body so its socket returns to the pool.
|
|
490
|
+
// An unread body pins the connection until GC — under a sustained
|
|
491
|
+
// 429/5xx storm the retries accumulated one open connection per attempt.
|
|
492
|
+
if (res && res.body) { try { await res.body.cancel(); } catch { /* already gone */ } }
|
|
449
493
|
// Small exponential backoff (200ms, 400ms). If the user hit Esc during
|
|
450
494
|
// the wait, stop retrying.
|
|
451
495
|
const tryingSignal = this.controller.signal;
|
|
@@ -469,7 +513,17 @@ export class LLM {
|
|
|
469
513
|
return;
|
|
470
514
|
}
|
|
471
515
|
if (cfg.stream === false) {
|
|
472
|
-
|
|
516
|
+
// A 200 with a malformed/empty body used to reject here, and the caller
|
|
517
|
+
// (an unawaited `runAgent`) turned that rejection into a process exit via
|
|
518
|
+
// the global unhandledRejection handler. Surface it as an error EVENT —
|
|
519
|
+
// the same channel every other failure on this path uses.
|
|
520
|
+
let json;
|
|
521
|
+
try {
|
|
522
|
+
json = await res.json();
|
|
523
|
+
} catch (e) {
|
|
524
|
+
onEvent({ type: 'error', error: new Error(`LLM returned a malformed non-streaming body: ${e && e.message}`) });
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
473
527
|
return consumeNonStreaming(cfg.protocol, json, onEvent);
|
|
474
528
|
}
|
|
475
529
|
if (!res.body) { onEvent({ type: 'end' }); return; }
|