feat: task persistence, memoized UI components, hook/delete-failure safety
locode-authored (dogfooded) changes:
- TaskStore.toJSON/fromJSON: task_create/list state now survives session
resume instead of resetting to empty (session.ts, task.ts, sessionStore.ts).
- StatusBar/ChatInput/ThinkingIndicator wrapped in memo() to skip re-renders
during the ~30fps streaming-text update loop.
- HistoryItemView's todos list keys off t.content instead of array index
(TodoItem has no id field; content is the best stable key available).
- sessionStore.ts read paths now log (console.warn) on corrupt/unreadable
files instead of silently swallowing the error.
Fixes on top, found in review:
- safeRunHooksForEvent() wraps the 4 remaining unprotected hook call sites
(PreToolUse, PermissionRequest, PostToolUse, SubagentStart) — these fire on
every tool call/sub-agent, far more often than the 3 sites already wrapped
(Stop/SessionStart/UserPromptSubmit), so a broken hook script was still able
to abort an in-flight turn.
- deleteSession() no longer reports success when unlinkSync fails for a
reason other than ENOENT (e.g. EPERM/EBUSY from a Windows file lock) — it
used to drop the index entry and return true anyway, orphaning the file on
disk with no way to reference it again. `sessions rm` in cli.ts now
distinguishes "not found" from "found but couldn't delete" in its message.
- ChatInput's memo() was a no-op: onSubmit={handleSubmit} passed a fresh
closure every render since handleSubmit isn't wrapped in useCallback.
Rather than force a ~300-line function (which reads isThinking/streamingText
— both changing every streaming frame) into a dependency array, added a
handleSubmitRef + stable wrapper, the same ref-indirection this file
already uses for phaseRef.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LxiAaGhSD4DRVYYQZ5GJjm
This commit is contained in:
+61
-20
@@ -29,12 +29,37 @@ function emitHookWarnings(warnings: string[], emit: AgentEventHandler): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Wraps runHooksForEvent for the events that fire on every tool call (PreToolUse, PostToolUse,
|
||||
* PermissionRequest) or every sub-agent (SubagentStart) — same "hooks must not derail things"
|
||||
* reasoning as fireStopHook/fireSessionStartHook/fireUserPromptSubmitHook, but those wrap a single
|
||||
* dedicated call site each; this one is shared since these fire from inline code in gateAndRun and
|
||||
* runSubAgentTurn rather than their own named functions. A broken hook (bad command, malformed
|
||||
* hooks.json) must not block a tool call or sub-agent it never got to properly evaluate. */
|
||||
async function safeRunHooksForEvent(
|
||||
...args: Parameters<typeof runHooksForEvent>
|
||||
): Promise<Awaited<ReturnType<typeof runHooksForEvent>>> {
|
||||
try {
|
||||
return await runHooksForEvent(...args);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${args[0]}] hook error (non-fatal):`, err);
|
||||
return { blocked: false, warnings: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop fires once the assistant has produced its final answer for a turn — informational only
|
||||
* (e.g. a desktop notification or logging hook); unlike PreToolUse it can't block, since there's
|
||||
* nothing left in this turn to prevent. */
|
||||
async function fireStopHook(session: Session, emit: AgentEventHandler, finalText: string): Promise<void> {
|
||||
const result = await runHooksForEvent("Stop", { sessionId: session.id, cwd: session.cwd }, { final_text: finalText });
|
||||
emitHookWarnings(result.warnings, emit);
|
||||
try {
|
||||
const result = await runHooksForEvent("Stop", { sessionId: session.id, cwd: session.cwd }, { final_text: finalText });
|
||||
emitHookWarnings(result.warnings, emit);
|
||||
} catch (err) {
|
||||
// Stop hooks are informational only (e.g. notifications, logging). A failing hook must never
|
||||
// discard the turn's final answer — log the error and continue.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[fireStopHook] hook error (non-fatal):", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs SessionStart hooks right after a session is created — any hook that exits 0 with stdout
|
||||
@@ -50,12 +75,19 @@ function foldHookContext(systemContent: string, result: Awaited<ReturnType<typeo
|
||||
}
|
||||
|
||||
export async function fireSessionStartHook(session: Session): Promise<string[]> {
|
||||
const result = await runHooksForEvent("SessionStart", { sessionId: session.id, cwd: session.cwd }, {});
|
||||
if (result.additionalContext || result.jsonContext?.length) {
|
||||
const systemMessage = session.messages[0] as ChatCompletionMessageParam & { content: string };
|
||||
systemMessage.content = foldHookContext(systemMessage.content, result);
|
||||
try {
|
||||
const result = await runHooksForEvent("SessionStart", { sessionId: session.id, cwd: session.cwd }, {});
|
||||
if (result.additionalContext || result.jsonContext?.length) {
|
||||
const systemMessage = session.messages[0] as ChatCompletionMessageParam & { content: string };
|
||||
systemMessage.content = foldHookContext(systemMessage.content, result);
|
||||
}
|
||||
return result.warnings;
|
||||
} catch (err) {
|
||||
// A failing SessionStart hook must not prevent the session from starting.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[fireSessionStartHook] hook error (non-fatal):", err);
|
||||
return [];
|
||||
}
|
||||
return result.warnings;
|
||||
}
|
||||
|
||||
export interface UserPromptSubmitResult {
|
||||
@@ -69,14 +101,21 @@ export interface UserPromptSubmitResult {
|
||||
/** Runs UserPromptSubmit hooks for a message the user is about to send — can block it outright
|
||||
* (e.g. a policy check) or inject extra context that gets appended alongside it. */
|
||||
export async function fireUserPromptSubmitHook(session: Session, prompt: string): Promise<UserPromptSubmitResult> {
|
||||
const result = await runHooksForEvent("UserPromptSubmit", { sessionId: session.id, cwd: session.cwd }, { prompt });
|
||||
return {
|
||||
blocked: result.blocked,
|
||||
reason: result.reason,
|
||||
additionalContext: result.additionalContext,
|
||||
jsonContext: result.jsonContext,
|
||||
warnings: result.warnings,
|
||||
};
|
||||
try {
|
||||
const result = await runHooksForEvent("UserPromptSubmit", { sessionId: session.id, cwd: session.cwd }, { prompt });
|
||||
return {
|
||||
blocked: result.blocked,
|
||||
reason: result.reason,
|
||||
additionalContext: result.additionalContext,
|
||||
jsonContext: result.jsonContext,
|
||||
warnings: result.warnings,
|
||||
};
|
||||
} catch (err) {
|
||||
// A failing UserPromptSubmit hook must not block the user from sending their message.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[fireUserPromptSubmitHook] hook error (non-fatal):", err);
|
||||
return { blocked: false, warnings: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentError extends Error {}
|
||||
@@ -550,7 +589,7 @@ async function gateAndRun(
|
||||
// PreToolUse fires before permission modes apply — a hook's block can't be bypassed by
|
||||
// auto-accept, same as real Claude Code hooks.
|
||||
const hookCtx = { sessionId: session.id, cwd: session.cwd };
|
||||
const preHook = await runHooksForEvent("PreToolUse", hookCtx, { tool_name: tool.name, tool_input: args }, tool.name);
|
||||
const preHook = await safeRunHooksForEvent("PreToolUse", hookCtx, { tool_name: tool.name, tool_input: args }, tool.name);
|
||||
emitHookWarnings(preHook.warnings, emit);
|
||||
if (preHook.blocked) {
|
||||
emit({ type: "tool_result", summary: `Blocked by hook: ${preHook.reason}`, isError: true });
|
||||
@@ -579,7 +618,7 @@ async function gateAndRun(
|
||||
const runMutatingSection = async (): Promise<unknown> => {
|
||||
if (tool.mutating && !session.permissions.isAutoApproved(tool.name)) {
|
||||
const preview = tool.preview ? await tool.preview(args, ctx) : undefined;
|
||||
const permissionHook = await runHooksForEvent(
|
||||
const permissionHook = await safeRunHooksForEvent(
|
||||
"PermissionRequest",
|
||||
hookCtx,
|
||||
{ tool_name: tool.name, tool_input: args, preview },
|
||||
@@ -626,7 +665,7 @@ async function gateAndRun(
|
||||
// an auto-format hook that rewrites the file the tool just wrote needs to finish before the next
|
||||
// tool runs or the model reads the file again. The tradeoff is that a slow logging hook will stall
|
||||
// the turn up to its own timeout — that's the cost of running formatting/logging synchronously.
|
||||
const postHook = await runHooksForEvent("PostToolUse", hookCtx, { tool_name: tool.name, tool_input: args, tool_response: result }, tool.name);
|
||||
const postHook = await safeRunHooksForEvent("PostToolUse", hookCtx, { tool_name: tool.name, tool_input: args, tool_response: result }, tool.name);
|
||||
emitHookWarnings(postHook.warnings, emit);
|
||||
|
||||
return result;
|
||||
@@ -767,7 +806,7 @@ async function runSubAgentTurn(
|
||||
// into the turn's own time budget, and so a hook that blocks (exit 2) actually prevents the
|
||||
// sub-agent from running instead of only being logged.
|
||||
const hookCtx = { sessionId: parent.id, cwd: parent.cwd };
|
||||
const startHook = await runHooksForEvent("SubagentStart", hookCtx, { description: task.description, prompt: task.prompt });
|
||||
const startHook = await safeRunHooksForEvent("SubagentStart", hookCtx, { description: task.description, prompt: task.prompt });
|
||||
if (startHook.warnings.length) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`SubagentStart hook warnings: ${startHook.warnings.join("; ")}`);
|
||||
@@ -964,10 +1003,12 @@ export async function runTurn(
|
||||
// rollback preserves it (dropping it would leave [system, recap] with no user turn again —
|
||||
// the very bug this user message exists to prevent).
|
||||
session.mutationCommitLength = session.messages.length;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// 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.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[compactSession] compaction failed (non-fatal):", err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-6
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import type OpenAI from "openai";
|
||||
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
|
||||
import type { ToolCallMode } from "../backend/capabilityProbe.js";
|
||||
import { DEFAULT_AUTO_COMPACT_THRESHOLD, DEFAULT_CONTEXT_WINDOW_LOCAL, DEFAULT_MAX_ITERATIONS } from "../config/defaults.js";
|
||||
import { DEFAULT_AUTO_COMPACT_THRESHOLD, DEFAULT_CONTEXT_WINDOW_CLOUD, DEFAULT_CONTEXT_WINDOW_LOCAL, DEFAULT_MAX_ITERATIONS } from "../config/defaults.js";
|
||||
import type { SessionRecord } from "../persistence/sessionStore.js";
|
||||
import { PermissionManager } from "../permissions/permissionManager.js";
|
||||
import type { ConfirmFn } from "../permissions/types.js";
|
||||
@@ -10,6 +10,7 @@ import { TOOLS } from "../tools/index.js";
|
||||
import { buildToolSet, type ToolSet } from "../tools/toolset.js";
|
||||
import type { TodoItem, ToolDef } from "../tools/types.js";
|
||||
import { TaskStore } from "../tools/task.js";
|
||||
import type { TaskStoreSnapshot } from "../tools/task.js";
|
||||
import { estimateTokens } from "../utils/tokens.js";
|
||||
import { buildSystemPrompt } from "./systemPrompt.js";
|
||||
|
||||
@@ -116,7 +117,7 @@ export function createSession(
|
||||
confirm: ConfirmFn,
|
||||
mode: ToolCallMode,
|
||||
tools: ToolDef[] = TOOLS,
|
||||
contextWindow: number = DEFAULT_CONTEXT_WINDOW_LOCAL,
|
||||
contextWindow?: number,
|
||||
contextWindowIsEstimate: boolean = true,
|
||||
maxIterations: number = DEFAULT_MAX_ITERATIONS,
|
||||
autoCompactThreshold: number = DEFAULT_AUTO_COMPACT_THRESHOLD,
|
||||
@@ -124,6 +125,7 @@ export function createSession(
|
||||
isLocal?: boolean,
|
||||
): Session {
|
||||
const resolvedIsLocal = isLocal ?? false;
|
||||
const resolvedContextWindow = contextWindow ?? (resolvedIsLocal ? DEFAULT_CONTEXT_WINDOW_LOCAL : DEFAULT_CONTEXT_WINDOW_CLOUD);
|
||||
const toolset = buildToolSet(tools);
|
||||
const messages: ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: buildSystemPrompt(toolset.tools, mode, projectInstructions, resolvedIsLocal) },
|
||||
@@ -142,7 +144,7 @@ export function createSession(
|
||||
confirm,
|
||||
toolset,
|
||||
subAgentDepth: 0,
|
||||
contextWindow,
|
||||
contextWindow: resolvedContextWindow,
|
||||
contextWindowIsEstimate,
|
||||
lastContextTokens: estimateTokens(messages),
|
||||
lastContextTokensIsEstimate: true,
|
||||
@@ -165,7 +167,7 @@ export function createSessionFromRecord(
|
||||
cwd: string,
|
||||
confirm: ConfirmFn,
|
||||
tools: ToolDef[] = TOOLS,
|
||||
contextWindow: number = DEFAULT_CONTEXT_WINDOW_LOCAL,
|
||||
contextWindow?: number,
|
||||
contextWindowIsEstimate: boolean = true,
|
||||
maxIterations: number = DEFAULT_MAX_ITERATIONS,
|
||||
autoCompactThreshold: number = DEFAULT_AUTO_COMPACT_THRESHOLD,
|
||||
@@ -173,6 +175,7 @@ export function createSessionFromRecord(
|
||||
isLocal?: boolean,
|
||||
): Session {
|
||||
const resolvedIsLocal = isLocal ?? false;
|
||||
const resolvedContextWindow = contextWindow ?? (resolvedIsLocal ? DEFAULT_CONTEXT_WINDOW_LOCAL : DEFAULT_CONTEXT_WINDOW_CLOUD);
|
||||
const toolset = buildToolSet(tools);
|
||||
const messages: ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: buildSystemPrompt(toolset.tools, record.mode, projectInstructions, resolvedIsLocal) },
|
||||
@@ -192,7 +195,7 @@ export function createSessionFromRecord(
|
||||
confirm,
|
||||
toolset,
|
||||
subAgentDepth: 0,
|
||||
contextWindow,
|
||||
contextWindow: resolvedContextWindow,
|
||||
contextWindowIsEstimate,
|
||||
lastContextTokens: estimateTokens(messages),
|
||||
lastContextTokensIsEstimate: true,
|
||||
@@ -202,7 +205,7 @@ export function createSessionFromRecord(
|
||||
mutationCommitLength: null,
|
||||
projectInstructions,
|
||||
todos: [],
|
||||
taskStore: new TaskStore(),
|
||||
taskStore: TaskStore.fromJSON(record.tasks ?? { seq: 0, tasks: [] }),
|
||||
mutationGate: Promise.resolve(),
|
||||
};
|
||||
|
||||
@@ -227,6 +230,7 @@ export function toSessionRecord(session: Session, baseURL: string): SessionRecor
|
||||
mode: session.mode,
|
||||
messages: session.messages.slice(1),
|
||||
allowedTools: session.permissions.listAllowed(),
|
||||
tasks: session.taskStore.toJSON(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -9,7 +9,7 @@ import { configFilePath, loadStoredConfig, saveStoredConfig, type StoredConfig }
|
||||
import { loadMergedHooks, userHooksFilePath } from "./hooks/config.js";
|
||||
import { loadMergedServers, removeUserServer, saveUserServer, userMcpFilePath } from "./mcp/config.js";
|
||||
import { isHttpServerConfig } from "./mcp/types.js";
|
||||
import { deleteSession, listSessions, mostRecentSessionId, sessionsDir } from "./persistence/sessionStore.js";
|
||||
import { deleteSession, listSessions, loadSession, mostRecentSessionId, sessionsDir } from "./persistence/sessionStore.js";
|
||||
import { addInstalledPlugin, loadInstalledPlugins, pluginsDir, removeInstalledPlugin } from "./plugins/config.js";
|
||||
import { loadPlugin } from "./plugins/loader.js";
|
||||
import { runInkApp } from "./ui/ink/index.js";
|
||||
@@ -230,6 +230,11 @@ sessionsCmd
|
||||
.action((id: string) => {
|
||||
if (deleteSession(id)) {
|
||||
console.log(`Deleted session ${id}.`);
|
||||
} else if (loadSession(id)) {
|
||||
// The file exists but deleteSession() couldn't actually remove it (e.g. locked by another
|
||||
// process) — a different situation from "no such session", so say so distinctly.
|
||||
console.error(`Could not delete session "${id}" — the file may be in use by another process.`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.error(`No saved session found with id "${id}".`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isCloudRoutedModelName, isLocalBackendURL, isSmallLocalModel } from "./defaults.js";
|
||||
import { DEFAULT_CONTEXT_WINDOW_CLOUD, DEFAULT_CONTEXT_WINDOW_LOCAL, isCloudRoutedModelName, isLocalBackendURL, isSmallLocalModel } from "./defaults.js";
|
||||
|
||||
describe("isLocalBackendURL", () => {
|
||||
it("recognizes localhost, 127.0.0.1, and ::1", () => {
|
||||
@@ -49,3 +49,13 @@ describe("isSmallLocalModel", () => {
|
||||
expect(isSmallLocalModel("https://api.openai.com/v1", "gpt-4")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_CONTEXT_WINDOW_LOCAL / CLOUD", () => {
|
||||
it("LOCAL is the small-local fallback (8 192)", () => {
|
||||
expect(DEFAULT_CONTEXT_WINDOW_LOCAL).toBe(8192);
|
||||
});
|
||||
|
||||
it("CLOUD is the cloud/large fallback (131 072)", () => {
|
||||
expect(DEFAULT_CONTEXT_WINDOW_CLOUD).toBe(131072);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { existsSync, mkdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// unlinkSync is mocked (default: pass-through to the real implementation) only so the "can't
|
||||
// actually delete the file" test below can make a single call fail — every other test's calls to
|
||||
// unlinkSync still hit the real filesystem via this same mock.
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
return { ...actual, unlinkSync: vi.fn(actual.unlinkSync) };
|
||||
});
|
||||
import envPaths from "env-paths";
|
||||
import {
|
||||
deleteSession,
|
||||
@@ -76,6 +84,19 @@ describe("sessionStore", () => {
|
||||
expect(listSessions()[0]?.id).toBe("manual-session");
|
||||
});
|
||||
|
||||
it("reports failure (not success) when the file can't actually be deleted", async () => {
|
||||
// Regression: a real unlink failure (e.g. Windows EPERM/EBUSY from a file lock) used to still
|
||||
// return true and drop the entry from the index — reporting success while orphaning the file
|
||||
// on disk with no way to reference it again.
|
||||
await saveSession(makeRecord("locked-session"));
|
||||
vi.mocked(unlinkSync).mockImplementationOnce(() => {
|
||||
throw Object.assign(new Error("EBUSY: resource busy or locked"), { code: "EBUSY" });
|
||||
});
|
||||
expect(deleteSession("locked-session")).toBe(false);
|
||||
expect(loadSession("locked-session")?.id).toBe("locked-session");
|
||||
expect(listSessions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("self-heals when a session file is deleted outside of deleteSession()", async () => {
|
||||
await saveSession(makeRecord("will-vanish"));
|
||||
expect(listSessions()).toHaveLength(1);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFil
|
||||
import path from "node:path";
|
||||
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
|
||||
import type { ToolCallMode } from "../backend/capabilityProbe.js";
|
||||
import type { TaskStoreSnapshot } from "../tools/task.js";
|
||||
import { writeFileAtomic } from "../utils/writeFileAtomic.js";
|
||||
|
||||
/** A saved conversation. `messages` excludes the system prompt — it's rebuilt fresh from the
|
||||
@@ -18,6 +19,8 @@ export interface SessionRecord {
|
||||
messages: ChatCompletionMessageParam[];
|
||||
/** Tool names the user approved "for this session" — preserved across resume. */
|
||||
allowedTools?: string[];
|
||||
/** Persisted task store snapshot so tasks survive session resume. */
|
||||
tasks?: TaskStoreSnapshot;
|
||||
}
|
||||
|
||||
export interface SessionSummary {
|
||||
@@ -77,7 +80,9 @@ function readIndex(): Map<string, SessionSummary> {
|
||||
try {
|
||||
const entries = JSON.parse(readFileSync(indexFilePath(), "utf-8")) as SessionSummary[];
|
||||
index = new Map(entries.map((e) => [e.id, e]));
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("[sessionStore] failed to parse index file, rebuilding:", err);
|
||||
index = new Map();
|
||||
}
|
||||
}
|
||||
@@ -93,8 +98,10 @@ function readIndex(): Map<string, SessionSummary> {
|
||||
try {
|
||||
const record = JSON.parse(readFileSync(path.join(dir, entry), "utf-8")) as SessionRecord;
|
||||
index.set(record.id, summarize(record));
|
||||
} catch {
|
||||
// Skip corrupt/partial session files
|
||||
} catch (err) {
|
||||
// Skip corrupt/partial session files — but log so disk issues aren't silent.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[sessionStore] skipping corrupt session file ${entry}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,12 +116,14 @@ function writeIndex(index: Map<string, SessionSummary>): void {
|
||||
try {
|
||||
writeFileSync(tmp, JSON.stringify([...index.values()]));
|
||||
renameSync(tmp, indexFilePath());
|
||||
} catch {
|
||||
} catch (err) {
|
||||
try {
|
||||
unlinkSync(tmp);
|
||||
} catch {
|
||||
// tmp may not have been created if the write itself failed
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("[sessionStore] failed to write session index:", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +177,12 @@ export function loadSession(id: string): SessionRecord | undefined {
|
||||
if (!existsSync(file)) return undefined;
|
||||
try {
|
||||
return JSON.parse(readFileSync(file, "utf-8")) as SessionRecord;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Corrupt or unreadable session file — treat as absent, but log so disk issues
|
||||
// aren't completely silent. Callers can't distinguish "no file" from "corrupt file",
|
||||
// but at least the log preserves the reason.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[sessionStore] failed to load session ${id}, treating as absent:`, err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -185,7 +199,20 @@ export function mostRecentSessionId(): string | undefined {
|
||||
export function deleteSession(id: string): boolean {
|
||||
const file = filePath(id);
|
||||
if (!existsSync(file)) return false;
|
||||
unlinkSync(file);
|
||||
try {
|
||||
unlinkSync(file);
|
||||
} catch (err) {
|
||||
// ENOENT is harmless (race with another process) — the end state (file gone) is what we
|
||||
// wanted anyway, so fall through and report success. Any other error (e.g. EPERM/EBUSY from
|
||||
// a file lock, common on Windows) means the file is still on disk — report failure and leave
|
||||
// the index entry alone, or listSessions()/resume would silently orphan a file no one could
|
||||
// reference again (removed from the index, but never actually deleted).
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[sessionStore] failed to delete session file ${file}:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const index = readIndex();
|
||||
index.delete(id);
|
||||
writeIndex(index);
|
||||
|
||||
@@ -36,6 +36,12 @@ export interface TaskSummary {
|
||||
blockedBy: string[];
|
||||
}
|
||||
|
||||
/** JSON-serializable shape for persisting a TaskStore. */
|
||||
export interface TaskStoreSnapshot {
|
||||
seq: number;
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
/** In-memory task store. The task tools operate on it via `ctx.taskStore`. Mutations emit a
|
||||
* snapshot through an optional `onChange` callback the loop wires up, so each create/update can
|
||||
* refresh a UI checklist. Purely in-memory (per-session); not persisted. */
|
||||
@@ -154,6 +160,24 @@ export class TaskStore {
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
|
||||
/** Serialize the entire store for persistence. */
|
||||
toJSON(): TaskStoreSnapshot {
|
||||
return {
|
||||
seq: this.seq,
|
||||
tasks: [...this.tasks.values()].map(serializeTask),
|
||||
};
|
||||
}
|
||||
|
||||
/** Restore a store from a previously-serialized snapshot. */
|
||||
static fromJSON(snapshot: TaskStoreSnapshot): TaskStore {
|
||||
const store = new TaskStore();
|
||||
store.seq = snapshot.seq;
|
||||
for (const task of snapshot.tasks) {
|
||||
store.tasks.set(task.id, { ...task, blocks: [...task.blocks], blockedBy: [...task.blockedBy], metadata: task.metadata ? { ...task.metadata } : undefined });
|
||||
}
|
||||
return store;
|
||||
}
|
||||
}
|
||||
|
||||
/** Merge-patches metadata: a null value deletes the key, any other value sets it. */
|
||||
|
||||
+15
-2
@@ -531,7 +531,7 @@ export function App({
|
||||
resolveToolCallMode(session.client, baseURLRef.current, session.model),
|
||||
resolveContextWindow(baseURLRef.current, session.model),
|
||||
]);
|
||||
// See the same recompute in switchModel — a backend switch changes isLocalBackendURL()'s
|
||||
// See the same recompute in switchModel — a backend switch changes isSmallLocalModel()'s
|
||||
// input just as much as a model switch changes isCloudRoutedModelName()'s.
|
||||
session.isLocal = isSmallLocalModel(baseURLRef.current, session.model);
|
||||
setMode(session, newMode);
|
||||
@@ -990,6 +990,19 @@ export function App({
|
||||
await submitTurn(session, extraContext ? `${trimmed}\n\n${extraContext}` : trimmed);
|
||||
}
|
||||
|
||||
// handleSubmit is a plain function recreated every render (it's 300 lines closing over dozens of
|
||||
// things, including isThinking/streamingText — both of which change during every streaming frame,
|
||||
// so a naive useCallback with a correct dependency list would still get a new identity constantly
|
||||
// while streaming, exactly when we want it stable). Instead, the same ref-indirection this file
|
||||
// already uses for phaseRef: keep a ref pointing at the latest handleSubmit, and hand ChatInput a
|
||||
// permanently-stable wrapper that just calls through it — this is what actually makes ChatInput's
|
||||
// memo() below effective during streaming, without needing handleSubmit's own identity to be stable.
|
||||
const handleSubmitRef = useRef(handleSubmit);
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
const stableHandleSubmit = useCallback((raw: string) => {
|
||||
void handleSubmitRef.current(raw);
|
||||
}, []);
|
||||
|
||||
function handlePermissionSelect(decision: PermissionDecision) {
|
||||
const pending = permission;
|
||||
setPermission(null);
|
||||
@@ -1089,7 +1102,7 @@ export function App({
|
||||
<ChatInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={stableHandleSubmit}
|
||||
cwd={cwd}
|
||||
history={history}
|
||||
availableColumns={chatColumns}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box, Text, useBoxMetrics, useCursor, useInput, useWindowSize, type DOMElement } from "ink";
|
||||
import fg from "fast-glob";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import stringWidth from "string-width";
|
||||
import { getAbsolutePosition } from "./absolutePosition.js";
|
||||
import { ACCENT_HEX } from "../theme.js";
|
||||
@@ -33,7 +33,7 @@ const PROMPT_WIDTH = 2;
|
||||
// Border (1 col each side) + paddingX={1} (1 col each side) around the bordered box's content.
|
||||
const BOX_CHROME_WIDTH = 4;
|
||||
|
||||
export function ChatInput({ value, onChange, onSubmit, cwd, history = [], availableColumns, isActive = true }: Props) {
|
||||
export const ChatInput = memo(function ChatInput({ value, onChange, onSubmit, cwd, history = [], availableColumns, isActive = true }: Props) {
|
||||
const [allFiles, setAllFiles] = useState<string[] | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
@@ -370,4 +370,4 @@ export function ChatInput({ value, onChange, onSubmit, cwd, history = [], availa
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -243,8 +243,8 @@ export const HistoryItemView = memo(function HistoryItemView({ item }: { item: H
|
||||
{item.todos.length === 0 ? (
|
||||
<Text dimColor>Todos: (cleared)</Text>
|
||||
) : (
|
||||
item.todos.map((t, i) => (
|
||||
<Text key={i} color={color[t.status]} dimColor={t.status === "pending"} strikethrough={t.status === "completed"}>
|
||||
item.todos.map((t) => (
|
||||
<Text key={t.content} color={color[t.status]} dimColor={t.status === "pending"} strikethrough={t.status === "completed"}>
|
||||
{" "}
|
||||
{icon[t.status]} {t.content}
|
||||
</Text>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { useEffect, useState } from "react";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { ACCENT_HEX } from "../theme.js";
|
||||
import type { PermissionMode } from "../../permissions/types.js";
|
||||
import type { GitInfo } from "../../utils/gitInfo.js";
|
||||
@@ -64,7 +64,7 @@ function formatElapsed(ms: number): string {
|
||||
return h > 0 ? `${h}h${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
export function StatusBar({
|
||||
export const StatusBar = memo(function StatusBar({
|
||||
model,
|
||||
mode,
|
||||
permMode,
|
||||
@@ -135,4 +135,4 @@ export function StatusBar({
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Text } from "ink";
|
||||
import Spinner from "ink-spinner";
|
||||
import { memo } from "react";
|
||||
import { ACCENT_HEX } from "../theme.js";
|
||||
|
||||
export function ThinkingIndicator({ label = "thinking..." }: { label?: string }) {
|
||||
export const ThinkingIndicator = memo(function ThinkingIndicator({ label = "thinking..." }: { label?: string }) {
|
||||
return (
|
||||
<Text color={ACCENT_HEX}>
|
||||
<Spinner type="dots" /> {label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user