Bump to 0.3.1: fix backend hangs, UI layout, and dead-code type error
A day of dogfooding locode on itself surfaced several real bugs, most centered on the backend request lifecycle silently hanging with no error surfaced to the user: - bash/hooks: execa's shell:true defaulted to cmd.exe on Windows, which lacks Unix tools (cat, sed, grep, ...) and fails a whole pipe with an unhelpful exit 255 if any stage isn't found. Added src/utils/shell.ts to resolve real Git Bash when available. - agent/loop: the backend client's own `timeout` only bounds time-to-first-byte — once a streaming response starts, a backend that goes silent mid-stream (or one that sends periodic content-less "heartbeat" chunks to keep the connection alive through a proxy) hung the request forever with zero output. Added an idle-abort guard that pokes only on chunks carrying real progress (usage/text/tool-calls/ finish_reason), covering the main stream, the non-streaming malformed-JSON retry, and compactSession. - agent/loop: a single tool-call-heavy turn (e.g. reading dozens of files) had no auto-compaction safety net at all — shouldAutoCompact was only ever checked between turns, never during one, so a long turn could blow past the context window with no mid-turn correction. Now checked every iteration; re-anchors mutationCommitLength afterward since compaction replaces session.messages wholesale. - agent/loop: hitting the iteration cap surfaced as an opaque "Request failed" even when every prior tool call (including file edits) had actually succeeded. Added MaxIterationsError, rendered as a calm "paused, send another message to continue" notice instead, and raised the default cap 25 -> 50. - agent/loop: removed a dead-code branch (fallback-malformed retry handling placed inside a native-mode-only code path, so session.mode === "fallback" could never be true there) that TypeScript correctly flagged as a type error. - ui/App: text_done fires with an empty fullText for pure tool-call turns (no preceding prose); the assistant-message push had no guard, rendering a blank line every time. Now skipped when fullText is empty. - ui/App+index: alternate-screen-buffer mode and StatusBar-above-ChatInput layout, fixing the reported "prompt starts below the dashboard, jumps up on space" glitch. Added input history (up/down recall). - utils/writeFileAtomic: random temp-file suffix instead of a fixed `<file>.tmp`, so concurrent writes to the same directory can't collide. Also fixes two hardcoded version strings (cli.ts --version, mcp/client.ts's self-reported MCP identity) that were left stale at 0.2.0 through the 0.3.0 bump, and a stale maxIterations default noted in the README. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -134,7 +134,7 @@ Config precedence: CLI flags > env vars (`LOCODE_BACKEND`, `LOCODE_MODEL`, `LOCO
|
||||
locode config set backend ollama
|
||||
locode config set model qwen3-coder:30b
|
||||
locode config set contextWindow 32768 # fallback size when auto-detection fails
|
||||
locode config set maxIterations 40 # max tool calls per turn before locode gives up (default 25)
|
||||
locode config set maxIterations 40 # max tool calls per turn before locode gives up (default 50)
|
||||
locode config set autoCompactThreshold 0.85 # fraction of context window at which auto-compact triggers
|
||||
locode config set requestTimeoutMs 300000 # per-request timeout in ms (default 180000); raise this if
|
||||
# your backend queues requests behind a concurrency limit
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "locode",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "locode",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@vscode/ripgrep": "^1.18.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "locode",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "Agentic coding CLI for local models served via Ollama and LM Studio",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
+4
-1
@@ -8,6 +8,9 @@ export type AgentEvent =
|
||||
| { type: "tool_call"; label: string }
|
||||
| { type: "tool_result"; summary: string; isError: boolean }
|
||||
/** A hook (see hooks/runner.ts) blocked something or failed non-fatally — surfaced as a notice. */
|
||||
| { type: "hook_notice"; text: string; isError: boolean };
|
||||
| { type: "hook_notice"; text: string; isError: boolean }
|
||||
/** A general informational notice from the loop itself (not tied to a hook) — e.g. a mid-turn
|
||||
* auto-compaction. Surfaced the same way as hook_notice. */
|
||||
| { type: "notice"; text: string; isError: boolean };
|
||||
|
||||
export type AgentEventHandler = (event: AgentEvent) => void;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createIdleAbort } from "./loop.js";
|
||||
|
||||
describe("createIdleAbort", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("aborts after idleMs with no poke()", () => {
|
||||
const guard = createIdleAbort(1000);
|
||||
expect(guard.signal.aborted).toBe(false);
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(guard.signal.aborted).toBe(false);
|
||||
vi.advanceTimersByTime(2);
|
||||
expect(guard.signal.aborted).toBe(true);
|
||||
expect(guard.didTimeOut()).toBe(true);
|
||||
guard.dispose();
|
||||
});
|
||||
|
||||
it("stays alive as long as poke() keeps resetting the timer", () => {
|
||||
const guard = createIdleAbort(1000);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
vi.advanceTimersByTime(700);
|
||||
guard.poke();
|
||||
}
|
||||
expect(guard.signal.aborted).toBe(false);
|
||||
expect(guard.didTimeOut()).toBe(false);
|
||||
guard.dispose();
|
||||
});
|
||||
|
||||
it("aborts if poking stops (simulating a mid-stream stall)", () => {
|
||||
const guard = createIdleAbort(1000);
|
||||
guard.poke();
|
||||
vi.advanceTimersByTime(700);
|
||||
guard.poke();
|
||||
// No more pokes after this — simulates the backend going silent mid-stream.
|
||||
vi.advanceTimersByTime(1001);
|
||||
expect(guard.signal.aborted).toBe(true);
|
||||
expect(guard.didTimeOut()).toBe(true);
|
||||
guard.dispose();
|
||||
});
|
||||
|
||||
it("propagates an external signal's abort without marking it as a timeout", () => {
|
||||
const external = new AbortController();
|
||||
const guard = createIdleAbort(1000, external.signal);
|
||||
external.abort();
|
||||
expect(guard.signal.aborted).toBe(true);
|
||||
expect(guard.didTimeOut()).toBe(false);
|
||||
guard.dispose();
|
||||
});
|
||||
|
||||
it("dispose() prevents a pending timer from firing later", () => {
|
||||
const guard = createIdleAbort(1000);
|
||||
guard.dispose();
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(guard.signal.aborted).toBe(false);
|
||||
expect(guard.didTimeOut()).toBe(false);
|
||||
});
|
||||
});
|
||||
+189
-1
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { shouldAutoCompact } from "./loop.js";
|
||||
import { z } from "zod";
|
||||
import { MaxIterationsError, runTurn, shouldAutoCompact } from "./loop.js";
|
||||
import { createSession } from "./session.js";
|
||||
import { buildToolSet } from "../tools/toolset.js";
|
||||
import type { ToolDef } from "../tools/types.js";
|
||||
import type { Session } from "./session.js";
|
||||
|
||||
describe("shouldAutoCompact", () => {
|
||||
@@ -15,3 +19,187 @@ describe("shouldAutoCompact", () => {
|
||||
expect(shouldAutoCompact(session)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runTurn / max iterations", () => {
|
||||
it("throws MaxIterationsError (not a generic error) when a model keeps calling tools forever", async () => {
|
||||
// A no-op tool the fake model calls on every single turn, forever — simulates a model that
|
||||
// never converges on a final text answer within the turn's iteration budget.
|
||||
const noopTool: ToolDef = {
|
||||
name: "noop",
|
||||
description: "does nothing",
|
||||
schema: z.object({}),
|
||||
mutating: false,
|
||||
handler: async () => ({ ok: true }),
|
||||
};
|
||||
const toolset = buildToolSet([noopTool]);
|
||||
|
||||
function makeChunk(index: number) {
|
||||
return {
|
||||
choices: [
|
||||
{
|
||||
delta: { tool_calls: [{ index: 0, id: `call_${index}`, function: { name: "noop", arguments: "{}" } }] },
|
||||
finish_reason: "tool_calls",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: vi.fn(async () => ({
|
||||
[Symbol.asyncIterator]: (() => {
|
||||
let yielded = false;
|
||||
return () => ({
|
||||
next: async () => {
|
||||
if (yielded) return { done: true, value: undefined };
|
||||
yielded = true;
|
||||
return { done: false, value: makeChunk(0) };
|
||||
},
|
||||
});
|
||||
})(),
|
||||
})),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const session = createSession(fakeClient, "test-model", process.cwd(), async () => "once", "native", []);
|
||||
session.toolset = toolset;
|
||||
session.maxIterations = 5;
|
||||
|
||||
await expect(runTurn(session, "keep going forever", () => {})).rejects.toThrow(MaxIterationsError);
|
||||
await expect(runTurn(session, "keep going forever", () => {})).rejects.toThrow(/Paused after 5 steps/);
|
||||
});
|
||||
|
||||
it("auto-compacts mid-turn instead of only checking between turns", async () => {
|
||||
// A tool-heavy turn (read a bunch of files, say) that never gives runTurn a chance to return to
|
||||
// the caller — App.tsx's shouldAutoCompact check only runs *between* turns, so without a
|
||||
// mid-turn check this scenario previously had no compaction safety net at all.
|
||||
const readTool: ToolDef = {
|
||||
name: "read",
|
||||
description: "reads a file",
|
||||
schema: z.object({}),
|
||||
mutating: false,
|
||||
handler: async () => ({ content: "x".repeat(1000) }),
|
||||
};
|
||||
const toolset = buildToolSet([readTool]);
|
||||
|
||||
function toolCallChunk(index: number) {
|
||||
return {
|
||||
choices: [
|
||||
{
|
||||
delta: { tool_calls: [{ index: 0, id: `call_${index}`, function: { name: "read", arguments: "{}" } }] },
|
||||
finish_reason: "tool_calls",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
function finalTextChunk() {
|
||||
return { choices: [{ delta: { content: "done" }, finish_reason: "stop" }] };
|
||||
}
|
||||
|
||||
let streamingCallCount = 0;
|
||||
let compactionCalls = 0;
|
||||
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: vi.fn(async (params: any) => {
|
||||
if (params.stream === false) {
|
||||
// The compactSession() request.
|
||||
compactionCalls++;
|
||||
return { choices: [{ message: { content: "a short summary" } }], usage: { prompt_tokens: 10, completion_tokens: 5 } };
|
||||
}
|
||||
streamingCallCount++;
|
||||
// First streaming call makes a tool call (to run up the fake context usage); second
|
||||
// (after compaction should have kicked in) returns final text.
|
||||
const chunk = streamingCallCount === 1 ? toolCallChunk(0) : finalTextChunk();
|
||||
let yielded = false;
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: async () => {
|
||||
if (yielded) return { done: true, value: undefined };
|
||||
yielded = true;
|
||||
return { done: false, value: chunk };
|
||||
},
|
||||
}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const session = createSession(fakeClient, "test-model", process.cwd(), async () => "once", "native", []);
|
||||
session.toolset = toolset;
|
||||
session.maxIterations = 10;
|
||||
// Force shouldAutoCompact to be true on every check so the mid-turn path is exercised
|
||||
// deterministically, without depending on real token estimation.
|
||||
session.contextWindow = 100;
|
||||
session.lastContextTokens = 1000;
|
||||
session.autoCompactThreshold = 0.5;
|
||||
|
||||
const notices: string[] = [];
|
||||
const result = await runTurn(session, "read a bunch of files", (event) => {
|
||||
if (event.type === "notice") notices.push(event.text);
|
||||
});
|
||||
|
||||
expect(result).toBe("done");
|
||||
expect(compactionCalls).toBeGreaterThan(0);
|
||||
expect(notices.some((n) => n.includes("auto-compacted mid-turn"))).toBe(true);
|
||||
// mutationCommitLength must be re-anchored to the post-compaction array, never left pointing
|
||||
// past the end of it (which would corrupt a later rollback via `messages.length = commitLength`).
|
||||
expect(session.mutationCommitLength).not.toBeNull();
|
||||
expect(session.mutationCommitLength!).toBeLessThanOrEqual(session.messages.length);
|
||||
});
|
||||
|
||||
it("times out a stream stuck sending only empty heartbeat chunks forever", async () => {
|
||||
// Some backends/proxies keep a stalled generation's connection alive with periodic empty chunks
|
||||
// (no content, no tool_calls, no finish_reason). If the idle guard poked on every yielded chunk
|
||||
// regardless of content, these would reset the timer forever and the request would hang
|
||||
// indefinitely with zero output — exactly what was reported. Use a short real timeout so the
|
||||
// test both runs fast and proves real wall-clock behavior (not fake-timer bookkeeping).
|
||||
const originalEnv = process.env.LOCODE_REQUEST_TIMEOUT_MS;
|
||||
process.env.LOCODE_REQUEST_TIMEOUT_MS = "10000"; // the resolver's own 10s floor
|
||||
|
||||
try {
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: vi.fn(async (_params: any, options: any) => {
|
||||
const signal: AbortSignal | undefined = options?.signal;
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: async () => {
|
||||
// A real aborted fetch stream ends/errors once its controller aborts — mimic that
|
||||
// instead of yielding forever, so this test actually depends on the guard firing.
|
||||
if (signal?.aborted) {
|
||||
const err = new Error("The operation was aborted.");
|
||||
err.name = "AbortError";
|
||||
throw err;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
return { done: false, value: { choices: [{ delta: {}, index: 0 }] } }; // heartbeat, no content
|
||||
},
|
||||
}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const session = createSession(fakeClient, "test-model", process.cwd(), async () => "once", "native", []);
|
||||
|
||||
const start = Date.now();
|
||||
await expect(runTurn(session, "hang forever behind heartbeats", () => {})).rejects.toThrow(
|
||||
/Backend stopped responding mid-stream/,
|
||||
);
|
||||
const elapsed = Date.now() - start;
|
||||
// Should trip at ~10s despite the stream having "yielded" the whole time — well under a minute,
|
||||
// which is the failure mode this guards against (the report was a 30-minute silent hang).
|
||||
expect(elapsed).toBeLessThan(20_000);
|
||||
} finally {
|
||||
if (originalEnv === undefined) delete process.env.LOCODE_REQUEST_TIMEOUT_MS;
|
||||
else process.env.LOCODE_REQUEST_TIMEOUT_MS = originalEnv;
|
||||
}
|
||||
}, 25_000);
|
||||
});
|
||||
|
||||
+150
-6
@@ -16,6 +16,7 @@ import { resolveToolInvocation, runTool, type ResolvedToolCall } from "../toolca
|
||||
import { formatCallLabel, summarizeToolResult } from "../ui/toolSummary.js";
|
||||
import { estimateTokens } from "../utils/tokens.js";
|
||||
import { runHooksForEvent } from "../hooks/runner.js";
|
||||
import { resolveRequestTimeoutMs } from "../config/config.js";
|
||||
import { buildSystemPrompt } from "./systemPrompt.js";
|
||||
import type { Session } from "./session.js";
|
||||
|
||||
@@ -77,6 +78,48 @@ export async function fireUserPromptSubmitHook(session: Session, prompt: string)
|
||||
|
||||
export class AgentError extends Error {}
|
||||
|
||||
/** Thrown when a turn hits session.maxIterations without producing a final answer. Distinct from
|
||||
* AgentError so the UI can render it as a soft pause rather than a failure — unlike a genuine
|
||||
* backend/tool error, nothing actually went wrong: every tool call up to this point (including any
|
||||
* file edits) already succeeded and is preserved (see noteMutationCommit/mutationCommitLength), the
|
||||
* model just ran out of budget mid-task. Sending another message resumes from where it left off. */
|
||||
export class MaxIterationsError extends AgentError {}
|
||||
|
||||
/** The backend client's own `timeout` option only bounds time-to-first-byte: once a streaming
|
||||
* response's headers arrive, `fetchWithTimeout` clears its abort timer, so a backend that accepts
|
||||
* the connection and then goes silent mid-stream (no more chunks, no close) hangs the request
|
||||
* forever with no error surfaced anywhere. This builds a signal that also aborts if `poke()` isn't
|
||||
* called again within `idleMs` — call it on every received chunk (or once for a non-streaming
|
||||
* response) to keep the request alive. Composes with an optional `externalSignal` (e.g. a
|
||||
* sub-agent's hard wall-clock timeout) so aborting either one aborts the request. */
|
||||
export function createIdleAbort(
|
||||
idleMs: number,
|
||||
externalSignal?: AbortSignal,
|
||||
): { signal: AbortSignal; poke: () => void; dispose: () => void; didTimeOut: () => boolean } {
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const arm = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, idleMs);
|
||||
};
|
||||
arm();
|
||||
const onExternalAbort = () => controller.abort();
|
||||
externalSignal?.addEventListener("abort", onExternalAbort, { once: true });
|
||||
return {
|
||||
signal: controller.signal,
|
||||
poke: arm,
|
||||
dispose: () => {
|
||||
clearTimeout(timer);
|
||||
externalSignal?.removeEventListener("abort", onExternalAbort);
|
||||
},
|
||||
didTimeOut: () => timedOut,
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_MALFORMED_RETRIES = 2;
|
||||
|
||||
// Sub-agent safety limits. The toolset already excludes `agent` for sub-agents (so a model can't
|
||||
@@ -132,12 +175,28 @@ export async function compactSession(session: Session): Promise<string> {
|
||||
];
|
||||
|
||||
const requestStart = Date.now();
|
||||
const res = await session.client.chat.completions.create({
|
||||
const compactGuard = createIdleAbort(resolveRequestTimeoutMs());
|
||||
let res;
|
||||
try {
|
||||
res = await session.client.chat.completions.create(
|
||||
{
|
||||
model: session.model,
|
||||
messages: requestMessages,
|
||||
stream: false,
|
||||
max_tokens: 1024,
|
||||
});
|
||||
},
|
||||
{ signal: compactGuard.signal },
|
||||
);
|
||||
} catch (err) {
|
||||
if (compactGuard.didTimeOut()) {
|
||||
throw new AgentError(
|
||||
`Compaction failed: backend stopped responding (no data for ${Math.round(resolveRequestTimeoutMs() / 1000)}s).`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
compactGuard.dispose();
|
||||
}
|
||||
session.stats.modelTimeMs += Date.now() - requestStart;
|
||||
recordUsage(session, res.usage);
|
||||
const summary = res.choices[0]?.message?.content;
|
||||
@@ -247,6 +306,19 @@ function pushPendingImages(session: Session, images: ImageAttachment[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** After a mutating tool has run successfully and its result message has been pushed onto
|
||||
* session.messages, record the current history length as a "commit point" so the turn's error
|
||||
* rollback (App.tsx submitTurn) doesn't erase the record of a mutation that already hit disk.
|
||||
* Without this, a later backend failure in the same turn would truncate history back to before the
|
||||
* turn, leaving the model blind to its own filesystem change and causing it to blindly retry the
|
||||
* edit (which then fails with "old_string not found", etc.). */
|
||||
function noteMutationCommit(session: Session, resolved: ResolvedToolCall, result: unknown): void {
|
||||
if ("error" in resolved) return;
|
||||
if (!resolved.tool.mutating) return;
|
||||
if (result && typeof result === "object" && "error" in (result as object)) return;
|
||||
session.mutationCommitLength = session.messages.length;
|
||||
}
|
||||
|
||||
async function gateAndRun(
|
||||
resolved: ResolvedToolCall,
|
||||
rawLabel: string,
|
||||
@@ -385,6 +457,7 @@ async function runSubAgentTurn(parent: Session, task: SubAgentTask, overrides?:
|
||||
// Independent from the parent's — a sub-agent's own bash calls aren't backgroundable via the
|
||||
// parent UI's Ctrl+B since sub-agent tool calls aren't shown mid-flight anyway (see class doc above).
|
||||
activeBackground: null,
|
||||
mutationCommitLength: null,
|
||||
};
|
||||
|
||||
// Run (and honor) the SubagentStart hook before arming the timeout, so a slow hook doesn't eat
|
||||
@@ -456,6 +529,7 @@ async function handleCompletedMessage(
|
||||
const label = call.type === "function" ? `${call.function.name}(${call.function.arguments})` : call.type;
|
||||
const result = await gateAndRun(resolved, label, session, emit);
|
||||
const image = pushToolResultMessage(session, "native", call.id, call.type === "function" ? call.function.name : call.type, result);
|
||||
noteMutationCommit(session, resolved, result);
|
||||
if (image) pendingImages.push(image);
|
||||
}
|
||||
pushPendingImages(session, pendingImages);
|
||||
@@ -476,6 +550,7 @@ async function handleCompletedMessage(
|
||||
const label = `${call.name}(${JSON.stringify(call.arguments)})`;
|
||||
const result = await gateAndRun(resolved, label, session, emit);
|
||||
pushToolResultMessage(session, "fallback", "", call.name, result);
|
||||
noteMutationCommit(session, resolved, result);
|
||||
}
|
||||
return { text: "", hadToolCalls: true };
|
||||
}
|
||||
@@ -500,9 +575,34 @@ export async function runTurn(
|
||||
): Promise<string> {
|
||||
session.messages.push({ role: "user", content: userInput } as ChatCompletionMessageParam);
|
||||
if (session.subAgentDepth === 0) session.stats.turns++;
|
||||
session.mutationCommitLength = null;
|
||||
let malformedRetries = 0;
|
||||
|
||||
for (let i = 0; i < session.maxIterations; i++) {
|
||||
// A single turn that makes many tool calls in a row (e.g. reading dozens of files) can blow
|
||||
// past the context window entirely within one runTurn call — the caller (App.tsx) only checks
|
||||
// shouldAutoCompact *between* turns, so without this a long tool-heavy turn had no compaction
|
||||
// safety net at all, and would keep sending an ever-growing prompt until the backend choked on
|
||||
// it or hung trying to process it. Check on every iteration, including the first, since a prior
|
||||
// turn's post-turn compaction may not have run (e.g. if it errored).
|
||||
if (shouldAutoCompact(session)) {
|
||||
try {
|
||||
await compactSession(session);
|
||||
// compactSession replaces session.messages wholesale, so any previously-recorded
|
||||
// mutationCommitLength now indexes into an array that no longer exists — a later error in
|
||||
// this same turn would roll back to a stale, out-of-bounds length (App.tsx's
|
||||
// `session.messages.length = commitLength ?? rollbackLength`), padding the array with empty
|
||||
// slots instead of truncating it. The compacted history is itself always a safe rollback
|
||||
// floor (nothing after it has happened yet), so re-anchor to its current length.
|
||||
session.mutationCommitLength = session.messages.length;
|
||||
emit({ type: "notice", text: "Context was getting full — auto-compacted mid-turn.", isError: false });
|
||||
} catch {
|
||||
// Best-effort: if compaction itself fails, proceed with the oversized context rather than
|
||||
// aborting the whole turn — the idle-abort guard on the next request still protects against
|
||||
// an outright hang, it'll just be a slower/costlier request.
|
||||
}
|
||||
}
|
||||
|
||||
// --- Streaming path ---
|
||||
let fullText = "";
|
||||
let finishReason: string | null = null;
|
||||
@@ -511,6 +611,7 @@ export async function runTurn(
|
||||
let usage: CompletionUsage | undefined;
|
||||
const requestStart = Date.now();
|
||||
|
||||
const idleGuard = createIdleAbort(resolveRequestTimeoutMs(), signal);
|
||||
try {
|
||||
const stream = await session.client.chat.completions.create(
|
||||
{
|
||||
@@ -521,7 +622,7 @@ export async function runTurn(
|
||||
stream_options: { include_usage: true },
|
||||
max_tokens: 4096,
|
||||
},
|
||||
signal ? { signal } : undefined,
|
||||
{ signal: idleGuard.signal },
|
||||
);
|
||||
|
||||
for await (const chunk of stream) {
|
||||
@@ -530,6 +631,16 @@ export async function runTurn(
|
||||
if (chunk.usage) usage = chunk.usage;
|
||||
|
||||
const choice = chunk.choices[0];
|
||||
// Some backends/proxies send periodic empty "heartbeat" chunks (no delta content, no
|
||||
// tool_calls, no finish_reason) to keep a long-lived connection alive through intermediaries
|
||||
// during slow generation. Poking the idle guard unconditionally on every yielded chunk would
|
||||
// let those reset the timer forever, defeating it entirely — a generation truly stuck for
|
||||
// 30+ minutes would never trip if the transport keeps trickling empty chunks the whole time.
|
||||
// Only chunks carrying real progress (usage, text, tool-call deltas, or a finish reason)
|
||||
// count as the backend being alive and working.
|
||||
const isMeaningfulChunk = !!chunk.usage || !!choice?.delta?.content || !!choice?.delta?.tool_calls || !!choice?.finish_reason;
|
||||
if (isMeaningfulChunk) idleGuard.poke();
|
||||
|
||||
if (!choice) continue;
|
||||
|
||||
const delta = choice.delta;
|
||||
@@ -567,7 +678,23 @@ export async function runTurn(
|
||||
if (fullText) {
|
||||
emit({ type: "text_done", fullText });
|
||||
}
|
||||
if (idleGuard.didTimeOut()) {
|
||||
throw new AgentError(
|
||||
`Backend stopped responding mid-stream (no data for ${Math.round(resolveRequestTimeoutMs() / 1000)}s) — connection aborted. The backend may have crashed or hung; try again.`,
|
||||
);
|
||||
}
|
||||
throw annotateIfImageRelated(streamErr, session.messages);
|
||||
} finally {
|
||||
idleGuard.dispose();
|
||||
}
|
||||
|
||||
// An idle-triggered abort can also surface as a clean (chunk-less) end of the async iterator
|
||||
// instead of a thrown error, depending on how far into the stream it landed — check unconditionally
|
||||
// rather than only in the catch above, or it'd silently fall through to "Empty response from model."
|
||||
if (idleGuard.didTimeOut()) {
|
||||
throw new AgentError(
|
||||
`Backend stopped responding mid-stream (no data for ${Math.round(resolveRequestTimeoutMs() / 1000)}s) — connection aborted. The backend may have crashed or hung; try again.`,
|
||||
);
|
||||
}
|
||||
|
||||
session.stats.modelTimeMs += Date.now() - requestStart;
|
||||
@@ -595,7 +722,10 @@ export async function runTurn(
|
||||
emit({ type: "stream_discard" });
|
||||
// Retry non-streaming for this turn
|
||||
const retryStart = Date.now();
|
||||
const res = await session.client.chat.completions.create(
|
||||
const retryGuard = createIdleAbort(resolveRequestTimeoutMs(), signal);
|
||||
let res;
|
||||
try {
|
||||
res = await session.client.chat.completions.create(
|
||||
{
|
||||
model: session.model,
|
||||
messages: session.messages,
|
||||
@@ -603,8 +733,18 @@ export async function runTurn(
|
||||
stream: false,
|
||||
max_tokens: 4096,
|
||||
},
|
||||
signal ? { signal } : undefined,
|
||||
{ signal: retryGuard.signal },
|
||||
);
|
||||
} catch (retryErr) {
|
||||
if (retryGuard.didTimeOut()) {
|
||||
throw new AgentError(
|
||||
`Backend stopped responding (no data for ${Math.round(resolveRequestTimeoutMs() / 1000)}s) — connection aborted. The backend may have crashed or hung; try again.`,
|
||||
);
|
||||
}
|
||||
throw retryErr;
|
||||
} finally {
|
||||
retryGuard.dispose();
|
||||
}
|
||||
session.stats.modelTimeMs += Date.now() - retryStart;
|
||||
recordUsage(session, res.usage);
|
||||
const message = res.choices[0]?.message;
|
||||
@@ -643,6 +783,7 @@ export async function runTurn(
|
||||
const label = `${tc.name}(${tc.arguments})`;
|
||||
const result = await gateAndRun(resolved, label, session, emit);
|
||||
const image = pushToolResultMessage(session, "native", tc.id, tc.name, result);
|
||||
noteMutationCommit(session, resolved, result);
|
||||
if (image) pendingImages.push(image);
|
||||
}
|
||||
pushPendingImages(session, pendingImages);
|
||||
@@ -663,6 +804,7 @@ export async function runTurn(
|
||||
const label = `${call.name}(${JSON.stringify(call.arguments)})`;
|
||||
const result = await gateAndRun(resolved, label, session, emit);
|
||||
pushToolResultMessage(session, "fallback", "", call.name, result);
|
||||
noteMutationCommit(session, resolved, result);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -687,5 +829,7 @@ export async function runTurn(
|
||||
throw new AgentError("Empty response from model.");
|
||||
}
|
||||
|
||||
throw new AgentError("Max tool-call iterations reached without a final answer.");
|
||||
throw new MaxIterationsError(
|
||||
`Paused after ${session.maxIterations} steps in this turn. Everything done so far (including any file edits) is saved — send another message to continue.`,
|
||||
);
|
||||
}
|
||||
@@ -70,6 +70,15 @@ export interface Session {
|
||||
* `bash`) is in flight; Ctrl+B in the UI flips `.requested` to detach it. Null the rest of the
|
||||
* time, including while non-backgroundable tools run. */
|
||||
activeBackground: { requested: boolean } | null;
|
||||
/** Message-array length recorded after the most recent successfully-applied mutating tool result
|
||||
* (or mid-turn auto-compaction) in the current turn, or null until one has happened. Used by the
|
||||
* turn's error-rollback path (App.tsx submitTurn) so a later backend failure doesn't erase the
|
||||
* history of a mutating tool that already changed the filesystem — erasing it would leave the
|
||||
* model blind to its own change and cause it to blindly retry the edit (which then fails with
|
||||
* "old_string not found"). Also re-anchored after a mid-turn compactSession call (runTurn), since
|
||||
* that replaces session.messages wholesale and any earlier index would otherwise point past the
|
||||
* end of the new, shorter array. Reset to null at the start of each turn in runTurn. */
|
||||
mutationCommitLength: number | null;
|
||||
}
|
||||
|
||||
export function createSession(
|
||||
@@ -106,6 +115,7 @@ export function createSession(
|
||||
autoCompactThreshold,
|
||||
stats: initialStats(),
|
||||
activeBackground: null,
|
||||
mutationCommitLength: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,6 +157,7 @@ export function createSessionFromRecord(
|
||||
autoCompactThreshold,
|
||||
stats: initialStats(),
|
||||
activeBackground: null,
|
||||
mutationCommitLength: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ function addBackendOptions(cmd: Command): Command {
|
||||
program
|
||||
.name("locode")
|
||||
.description("Agentic coding CLI for local models via Ollama and LM Studio")
|
||||
.version("0.2.0");
|
||||
.version("0.3.1");
|
||||
|
||||
addBackendOptions(program)
|
||||
.option("-m, --model <name>", "model name as known to the backend")
|
||||
|
||||
@@ -12,9 +12,11 @@ export type BackendName = keyof typeof KNOWN_BACKENDS;
|
||||
* and the user hasn't configured one — a conservative size common among smaller local models. */
|
||||
export const DEFAULT_CONTEXT_WINDOW = 8192;
|
||||
|
||||
/** Max tool calls per turn before locode gives up rather than looping forever. 25 gives real
|
||||
* multi-file tasks room to breathe; still bounded so a genuinely stuck model fails fast. */
|
||||
export const DEFAULT_MAX_ITERATIONS = 25;
|
||||
/** Max tool calls per turn before locode gives up rather than looping forever. 50 gives real
|
||||
* multi-file tasks room to breathe (local models often issue one tool call per turn, so a
|
||||
* multi-file edit + verify sequence can easily run past 25); still bounded so a genuinely stuck
|
||||
* model fails fast, and hitting the cap is a soft pause, not a failure (see MaxIterationsError). */
|
||||
export const DEFAULT_MAX_ITERATIONS = 50;
|
||||
|
||||
/** Fraction of the context window at which locode automatically summarizes the conversation.
|
||||
* User-configurable via `locode config set autoCompactThreshold`. */
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import { execa } from "execa";
|
||||
import { loadMergedHooks } from "./config.js";
|
||||
import { matcherMatches } from "./matcher.js";
|
||||
import { resolveShell } from "../utils/shell.js";
|
||||
import type { Hook, HookCommand, HookEventName, HookHttp } from "./types.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_SECONDS = 30;
|
||||
@@ -55,7 +56,7 @@ async function runCommandHook(
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string; timedOut: boolean; json?: unknown; warning?: string }> {
|
||||
try {
|
||||
const result = await execa(hook.command, {
|
||||
shell: true,
|
||||
shell: resolveShell(),
|
||||
cwd: ctx.cwd,
|
||||
input: stdinPayload,
|
||||
timeout: (hook.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ export async function connectMcpServer(name: string, config: McpServerConfig): P
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const client = new Client({ name: "locode", version: "0.2.0" });
|
||||
const client = new Client({ name: "locode", version: "0.3.1" });
|
||||
await client.connect(transport);
|
||||
const { tools } = await client.listTools();
|
||||
return { name, client, transport, tools: tools as McpToolInfo[] };
|
||||
|
||||
+2
-1
@@ -3,6 +3,7 @@ import { execa } from "execa";
|
||||
import { z } from "zod";
|
||||
import { registerBackgroundJob } from "./backgroundJobs.js";
|
||||
import { truncate } from "../utils/truncate.js";
|
||||
import { resolveShell } from "../utils/shell.js";
|
||||
import type { ToolDef } from "./types.js";
|
||||
|
||||
const schema = z.object({
|
||||
@@ -29,7 +30,7 @@ export const bashTool: ToolDef<z.infer<typeof schema>> = {
|
||||
// backgrounding via Ctrl+B can cancel it below — execa's own timeout kills the process on a
|
||||
// fixed schedule regardless of what happens to it afterward, which would silently kill a
|
||||
// long-running command right after the user chose to keep it running in the background.
|
||||
const child = execa(command, { shell: true, cwd: workDir, reject: false });
|
||||
const child = execa(command, { shell: resolveShell(), cwd: workDir, reject: false });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const onStdout = (d: Buffer) => {
|
||||
|
||||
+11
-1
@@ -17,7 +17,17 @@ export const listFilesTool: ToolDef<z.infer<typeof schema>> = {
|
||||
mutating: false,
|
||||
handler: async ({ pattern, cwd }, ctx) => {
|
||||
const base = cwd ? path.resolve(ctx.cwd, cwd) : ctx.cwd;
|
||||
const matches = await fg(pattern, { cwd: base, dot: false, onlyFiles: true, absolute: false });
|
||||
const matches = await fg(pattern, {
|
||||
cwd: base,
|
||||
dot: false,
|
||||
onlyFiles: true,
|
||||
absolute: false,
|
||||
// node_modules and .git are pure noise for an agent exploring a project and are
|
||||
// always excluded. Build output dirs (dist/, build/, out/) are NOT excluded
|
||||
// here — the agent may legitimately need to inspect compiled output (e.g. to
|
||||
// verify a build), and hiding them would silently make those files invisible.
|
||||
ignore: ["**/node_modules/**", "**/.git/**"],
|
||||
});
|
||||
return { matches: matches.slice(0, MAX_MATCHES), truncated: matches.length > MAX_MATCHES };
|
||||
},
|
||||
};
|
||||
|
||||
+41
-5
@@ -6,6 +6,7 @@ import {
|
||||
contextUsageRatio,
|
||||
fireSessionStartHook,
|
||||
fireUserPromptSubmitHook,
|
||||
MaxIterationsError,
|
||||
runTurn,
|
||||
shouldAutoCompact,
|
||||
type ChatCompletionUserContent,
|
||||
@@ -57,6 +58,9 @@ import { StatusBar } from "./StatusBar.js";
|
||||
import { ThinkingIndicator } from "./ThinkingIndicator.js";
|
||||
import { nextId, type HistoryItem, type NewHistoryItem } from "./types.js";
|
||||
|
||||
// Cap for the input-history ring buffer used for ↑/↓ recall in the chat input.
|
||||
const MAX_HISTORY = 100;
|
||||
|
||||
export interface AppProps {
|
||||
baseURL: string;
|
||||
cwd: string;
|
||||
@@ -101,6 +105,7 @@ export function App({
|
||||
const [modelList, setModelList] = useState<string[]>([]);
|
||||
const [sessionList, setSessionList] = useState<SessionSummary[]>([]);
|
||||
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null);
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
|
||||
const baseURLRef = useRef(initialBaseURL);
|
||||
const sessionRef = useRef<Session | null>(null);
|
||||
@@ -443,7 +448,11 @@ export function App({
|
||||
}
|
||||
setStreamingText(null);
|
||||
streamingAccumulatorRef.current = "";
|
||||
// A native tool-call-only turn (no preceding prose) still emits text_done with an empty
|
||||
// fullText purely to flush streaming state above — skip adding a blank assistant line for it.
|
||||
if (event.fullText.trim().length > 0) {
|
||||
setStaticItems((prev) => [...prev, { id: nextId(), kind: "assistant", text: event.fullText } as HistoryItem]);
|
||||
}
|
||||
} else if (event.type === "stream_discard") {
|
||||
// The turn is being retried non-streaming after a partially-streamed malformed tool call —
|
||||
// drop the partial text without committing it as an assistant message (it was never added to
|
||||
@@ -455,6 +464,10 @@ export function App({
|
||||
setStreamingText(null);
|
||||
streamingAccumulatorRef.current = "";
|
||||
} else if (event.type === "tool_call") {
|
||||
if (streamRafRef.current !== null) {
|
||||
clearTimeout(streamRafRef.current);
|
||||
streamRafRef.current = null;
|
||||
}
|
||||
setStreamingText(null);
|
||||
setIsThinking(true);
|
||||
setRunningToolIsBash(event.label.startsWith("Bash("));
|
||||
@@ -462,7 +475,7 @@ export function App({
|
||||
} else if (event.type === "tool_result") {
|
||||
setRunningToolIsBash(false);
|
||||
setStaticItems((prev) => [...prev, { id: nextId(), kind: "tool_result", summary: event.summary, isError: event.isError } as HistoryItem]);
|
||||
} else if (event.type === "hook_notice") {
|
||||
} else if (event.type === "hook_notice" || event.type === "notice") {
|
||||
setStaticItems((prev) => [...prev, { id: nextId(), kind: "notice", text: event.text, isError: event.isError } as HistoryItem]);
|
||||
}
|
||||
});
|
||||
@@ -496,9 +509,21 @@ export function App({
|
||||
}
|
||||
setStreamingText(null);
|
||||
streamingAccumulatorRef.current = "";
|
||||
session.messages.length = rollbackLength;
|
||||
// Roll back the uncommitted tail of the turn — but preserve history through the last
|
||||
// successfully-applied mutating tool result (session.mutationCommitLength), so a backend
|
||||
// failure that comes *after* a mutation already hit disk doesn't erase the record of that
|
||||
// mutation. Erasing it would leave the model blind to its own change and cause it to blindly
|
||||
// retry the edit on the next turn (which then fails with "old_string not found", etc.).
|
||||
const commitLength = session.mutationCommitLength;
|
||||
session.messages.length = commitLength ?? rollbackLength;
|
||||
if (err instanceof MaxIterationsError) {
|
||||
// Not a failure — the model just ran out of per-turn budget. No "Request failed" framing,
|
||||
// no isError styling, since nothing actually broke and the work done so far is intact.
|
||||
push({ kind: "notice", text: err.message, isError: false });
|
||||
} else {
|
||||
const reason = err instanceof AgentError ? err.message : (err as Error).message;
|
||||
push({ kind: "notice", text: `Request failed: ${reason}`, isError: true });
|
||||
}
|
||||
} finally {
|
||||
setIsThinking(false);
|
||||
setStreamingText(null);
|
||||
@@ -514,6 +539,15 @@ export function App({
|
||||
const session = sessionRef.current;
|
||||
if (!session) return;
|
||||
|
||||
// Record into the input history (most-recent-last) for ↑/↓ recall. Skip exact
|
||||
// consecutive duplicates and cap the length so a long session doesn't grow
|
||||
// the array unbounded — same UX convention as a typical shell.
|
||||
setHistory((h) => {
|
||||
if (h.length > 0 && h[h.length - 1] === trimmed) return h;
|
||||
const next = [...h, trimmed];
|
||||
return next.length > MAX_HISTORY ? next.slice(next.length - MAX_HISTORY) : next;
|
||||
});
|
||||
|
||||
push({ kind: "user", text: trimmed });
|
||||
|
||||
if (trimmed === "/exit" || trimmed === "/quit") {
|
||||
@@ -807,7 +841,7 @@ export function App({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%" height="100%">
|
||||
<Box flexDirection="column" width="100%" height={phase === "input" ? undefined : "100%"}>
|
||||
<Static items={staticItems}>
|
||||
{(item) => <HistoryItemView key={item.id} item={item} />}
|
||||
</Static>
|
||||
@@ -839,8 +873,7 @@ export function App({
|
||||
) : phase === "model-select" ? (
|
||||
<ModelSelect models={modelList} currentModel={suggestedModel} onSelect={handleModelSelect} />
|
||||
) : (
|
||||
<ChatInput value={inputValue} onChange={setInputValue} onSubmit={handleSubmit} onCyclePermMode={cyclePermMode} cwd={cwd} />
|
||||
)}
|
||||
<Box flexDirection="column">
|
||||
{sessionRef.current && phase === "input" && (
|
||||
<StatusBar
|
||||
model={sessionRef.current.model}
|
||||
@@ -857,6 +890,9 @@ export function App({
|
||||
gitInfo={gitInfo}
|
||||
/>
|
||||
)}
|
||||
<ChatInput value={inputValue} onChange={setInputValue} onSubmit={handleSubmit} onCyclePermMode={cyclePermMode} cwd={cwd} history={history} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,14 +11,17 @@ interface Props {
|
||||
onSubmit: (value: string) => void;
|
||||
onCyclePermMode?: () => void;
|
||||
cwd: string;
|
||||
history?: string[];
|
||||
}
|
||||
|
||||
const MAX_MATCHES = 50;
|
||||
const VISIBLE_SUGGESTIONS = 8;
|
||||
|
||||
export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd }: Props) {
|
||||
export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd, history = [] }: Props) {
|
||||
const [allFiles, setAllFiles] = useState<string[] | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
const [tempValue, setTempValue] = useState("");
|
||||
// ink-text-input tracks its cursor position internally and has no way to be told the value
|
||||
// changed externally — without this, accepting a suggestion leaves the cursor at its old
|
||||
// (now-wrong) offset, so further typing lands mid-string instead of at the end. Bumping this
|
||||
@@ -96,13 +99,38 @@ export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd }: P
|
||||
cancelMention();
|
||||
return;
|
||||
}
|
||||
if (matches.length === 0) return;
|
||||
if (matches.length > 0) {
|
||||
if (key.downArrow) {
|
||||
setSelectedIndex((i) => Math.min(i + 1, matches.length - 1));
|
||||
return;
|
||||
} else if (key.upArrow) {
|
||||
setSelectedIndex((i) => Math.max(i - 1, 0));
|
||||
return;
|
||||
} else if (key.tab) {
|
||||
acceptSuggestion(matches[selectedIndex] ?? matches[0]!);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// History navigation
|
||||
if (key.upArrow) {
|
||||
if (historyIndex < history.length - 1) {
|
||||
const newIndex = historyIndex + 1;
|
||||
if (historyIndex === -1) setTempValue(value);
|
||||
setHistoryIndex(newIndex);
|
||||
onChange(history[history.length - 1 - newIndex]!);
|
||||
setInputKey((k) => k + 1);
|
||||
}
|
||||
return;
|
||||
} else if (key.downArrow) {
|
||||
if (historyIndex > -1) {
|
||||
const newIndex = historyIndex - 1;
|
||||
setHistoryIndex(newIndex);
|
||||
const newValue = newIndex === -1 ? tempValue : history[history.length - 1 - newIndex]!;
|
||||
onChange(newValue);
|
||||
setInputKey((k) => k + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -113,6 +141,8 @@ export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd }: P
|
||||
acceptSuggestion(matches[selectedIndex] ?? matches[0]!);
|
||||
return;
|
||||
}
|
||||
setHistoryIndex(-1);
|
||||
setTempValue("");
|
||||
onSubmit(raw);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,12 @@ export async function runInkApp(opts: RunInkAppOptions): Promise<void> {
|
||||
|
||||
// Start the UI immediately — no blocking network calls before rendering.
|
||||
// Model listing happens inside the App component so the user sees the UI right away.
|
||||
//
|
||||
// NOTE: we deliberately do NOT enter the alternate screen buffer (\x1b[?1049h) here.
|
||||
// Alt-screen has no scrollback, so the user couldn't scroll up through history with
|
||||
// the mouse wheel / Shift+PgUp. The dashboard-above-input layout in App.tsx already
|
||||
// fixes the original "prompt starts below the status bar" glitch without sacrificing
|
||||
// the terminal's native scrollback — keep the main screen.
|
||||
const instance = render(
|
||||
<App
|
||||
baseURL={opts.baseURL}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("node:fs", () => ({ existsSync: vi.fn() }));
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { _resetShellCache, resolveShell } from "./shell.js";
|
||||
|
||||
const mockedExistsSync = vi.mocked(existsSync);
|
||||
|
||||
describe("resolveShell", () => {
|
||||
const originalPlatform = process.platform;
|
||||
const originalPath = process.env.PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
_resetShellCache();
|
||||
mockedExistsSync.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform });
|
||||
process.env.PATH = originalPath;
|
||||
});
|
||||
|
||||
it("returns true (default shell) on non-Windows platforms", () => {
|
||||
Object.defineProperty(process, "platform", { value: "linux" });
|
||||
expect(resolveShell()).toBe(true);
|
||||
expect(mockedExistsSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("finds Git Bash via a git.exe directory on PATH", () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32" });
|
||||
process.env.PATH = ["C:\\Program Files\\Git\\cmd", "C:\\Windows\\System32"].join(path.delimiter);
|
||||
mockedExistsSync.mockImplementation((p) => p === "C:\\Program Files\\Git\\bin\\bash.exe");
|
||||
|
||||
expect(resolveShell()).toBe("C:\\Program Files\\Git\\bin\\bash.exe");
|
||||
});
|
||||
|
||||
it("falls back to well-known install locations when PATH has no git dir", () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32" });
|
||||
process.env.PATH = "C:\\Windows\\System32";
|
||||
mockedExistsSync.mockImplementation((p) => p === "C:\\Program Files\\Git\\bin\\bash.exe");
|
||||
|
||||
expect(resolveShell()).toBe("C:\\Program Files\\Git\\bin\\bash.exe");
|
||||
});
|
||||
|
||||
it("falls back to true (cmd.exe) when Git Bash can't be found anywhere", () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32" });
|
||||
process.env.PATH = "C:\\Windows\\System32";
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
|
||||
expect(resolveShell()).toBe(true);
|
||||
});
|
||||
|
||||
it("memoizes the result across calls", () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32" });
|
||||
process.env.PATH = "C:\\Windows\\System32";
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
|
||||
resolveShell();
|
||||
resolveShell();
|
||||
expect(mockedExistsSync).toHaveBeenCalledTimes(WINDOWS_GIT_BASH_CANDIDATE_COUNT);
|
||||
});
|
||||
});
|
||||
|
||||
const WINDOWS_GIT_BASH_CANDIDATE_COUNT = 2;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/** Candidate Git-for-Windows install locations, checked in order. Git Bash ships `bash.exe` plus a
|
||||
* real POSIX `usr/bin` (cat, sed, grep, rm, ...) — without it, execa's `shell: true` falls back to
|
||||
* cmd.exe, which has none of those and returns exit 255 on the first unresolved command in a pipe. */
|
||||
const WINDOWS_GIT_BASH_CANDIDATES = [
|
||||
"C:\\Program Files\\Git\\bin\\bash.exe",
|
||||
"C:\\Program Files (x86)\\Git\\bin\\bash.exe",
|
||||
];
|
||||
|
||||
let cachedShell: string | true | undefined;
|
||||
|
||||
/** Resolves the shell execa should use for `shell: true`-style commands. On Windows, prefers Git
|
||||
* Bash (via PATH's `git.exe` location, then well-known install dirs) so LLM-generated Unix
|
||||
* commands (cat, sed, grep, rm -rf, &&, ...) behave as the model expects instead of silently
|
||||
* failing under cmd.exe. Falls back to `true` (the platform default shell) everywhere else. */
|
||||
export function resolveShell(): string | true {
|
||||
if (cachedShell !== undefined) return cachedShell;
|
||||
cachedShell = findShell();
|
||||
return cachedShell;
|
||||
}
|
||||
|
||||
function findShell(): string | true {
|
||||
if (process.platform !== "win32") return true;
|
||||
|
||||
const pathVar = process.env.PATH ?? process.env.Path ?? "";
|
||||
for (const dir of pathVar.split(path.delimiter)) {
|
||||
if (!dir) continue;
|
||||
// git.exe on PATH lives in <root>\cmd or <root>\bin; Git Bash's own bash.exe is at <root>\bin.
|
||||
const root = path.basename(dir).toLowerCase() === "cmd" || path.basename(dir).toLowerCase() === "bin" ? path.dirname(dir) : null;
|
||||
if (root) {
|
||||
const candidate = path.join(root, "bin", "bash.exe");
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of WINDOWS_GIT_BASH_CANDIDATES) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Test-only: clears the memoized shell resolution. */
|
||||
export function _resetShellCache(): void {
|
||||
cachedShell = undefined;
|
||||
}
|
||||
@@ -1,13 +1,25 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdirSync, unlinkSync } from "node:fs";
|
||||
import { rename as fsRename, writeFile as fsWriteFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
/** Writes `content` to `file` atomically: write to a sibling temp file, then rename into place, so a
|
||||
* crash mid-write can never leave a truncated/corrupt file at `file` — a reader always sees either
|
||||
* the old complete content or the new complete content, never a partial write. */
|
||||
* the old complete content or the new complete content, never a partial write. The temp file uses a
|
||||
* random suffix (not a fixed `<file>.tmp`) so concurrent writes to the same directory can't collide
|
||||
* and overwrite each other's temp file mid-write. */
|
||||
export async function writeFileAtomic(file: string, content: string): Promise<void> {
|
||||
mkdirSync(path.dirname(file), { recursive: true });
|
||||
const tmp = `${file}.tmp`;
|
||||
const tmp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
|
||||
try {
|
||||
await fsWriteFile(tmp, content, "utf-8");
|
||||
await fsRename(tmp, file);
|
||||
} catch (err) {
|
||||
try {
|
||||
unlinkSync(tmp);
|
||||
} catch {
|
||||
// The temp file may not exist if fsWriteFile failed; ignore.
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user