🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@iinm/plain-agent

Package Overview
Dependencies
Maintainers
1
Versions
121
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@iinm/plain-agent - npm Package Compare versions

Comparing version
1.14.8
to
1.15.0
+85
src/utils/createAsyncQueue.mjs
/**
* @template T
* @typedef {{
* push: (value: T) => void,
* close: () => void,
* [Symbol.asyncIterator]: () => AsyncIterator<T>,
* }} AsyncQueue
*/
/**
* Create an unbounded async queue that converts a push-based producer into a
* pull-based async iterable. Values pushed while no consumer is waiting are
* buffered and delivered in FIFO order; pending `next()` calls are resolved
* immediately when a value is pushed.
*
* This hides the push/pull impedance mismatch behind a single AsyncIterable
* so callers can consume events with `for await`.
*
* @template T
* @returns {AsyncQueue<T>}
*
* @example
* const queue = createAsyncQueue();
* queue.push("a");
* for await (const value of queue) {
* console.log(value);
* }
*/
export function createAsyncQueue() {
/** @type {T[]} */
const buffer = [];
/** @type {((result: IteratorResult<T>) => void)[]} */
const waiters = [];
let closed = false;
return {
/**
* Enqueue a value. Resolves a pending consumer if one is waiting,
* otherwise buffers the value. No-op once the queue is closed.
* @param {T} value
*/
push(value) {
if (closed) return;
const waiter = waiters.shift();
if (waiter) {
waiter({ value, done: false });
} else {
buffer.push(value);
}
},
/**
* Close the queue. Buffered values are still delivered, then iteration
* ends. Pending consumers are resolved with `done: true`.
*/
close() {
closed = true;
while (waiters.length > 0) {
const waiter = waiters.shift();
waiter?.({ value: undefined, done: true });
}
},
[Symbol.asyncIterator]() {
return {
next() {
if (buffer.length > 0) {
const value = /** @type {T} */ (buffer.shift());
return Promise.resolve({ value, done: false });
}
if (closed) {
return Promise.resolve({ value: undefined, done: true });
}
return new Promise((resolve) => {
waiters.push(resolve);
});
},
return() {
closed = true;
return Promise.resolve({ value: undefined, done: true });
},
};
},
};
}
+4
-5
{
"name": "@iinm/plain-agent",
"version": "1.14.8",
"version": "1.15.0",
"description": "A lightweight terminal-based coding agent focused on safety and low token cost",

@@ -28,3 +28,2 @@ "license": "MIT",

"!src/**/*.playground.mjs",
"!src/test-reporter.mjs",
"!src/**/test/"

@@ -37,5 +36,5 @@ ],

"check": "npm run lint && tsc && npm run test && npm run test:e2e && npm run test:predefined-approval",
"test": "node --test --test-reporter=./src/test-reporter.mjs 'src/**/*.test.mjs'",
"test:e2e": "node --test --test-reporter=./src/test-reporter.mjs --test-timeout=30000 'e2e/**/*.test.mjs'",
"coverage": "c8 --exclude='src/**/*.test.mjs' --exclude='e2e/**' node --test --test-reporter=./src/test-reporter.mjs --test-force-exit 'src/**/*.test.mjs' 'e2e/**/*.test.mjs'",
"test": "node --test --test-reporter=./testReporter.mjs 'src/**/*.test.mjs'",
"test:e2e": "node --test --test-reporter=./testReporter.mjs --test-timeout=30000 'e2e/**/*.test.mjs'",
"coverage": "c8 --exclude='src/**/*.test.mjs' --exclude='e2e/**' node --test --test-reporter=./testReporter.mjs --test-force-exit 'src/**/*.test.mjs' 'e2e/**/*.test.mjs'",
"test:predefined-approval": "bash -c 'set -e; mkdir -p tmp; cd tmp; env HOME=. ../bin/plain test-approval'",

@@ -42,0 +41,0 @@ "lint": "npx @biomejs/biome check",

# Plain Agent
[![CodeQL](https://github.com/iinm/plain-agent/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/iinm/plain-agent/actions/workflows/github-code-scanning/codeql)
[![Socket Badge](https://badge.socket.dev/npm/package/@iinm/plain-agent/1.14.8)](https://socket.dev/npm/package/@iinm/plain-agent)
[![Socket Badge](https://badge.socket.dev/npm/package/@iinm/plain-agent/1.15.0)](https://socket.dev/npm/package/@iinm/plain-agent)
[![install size](https://packagephobia.com/badge?p=@iinm/plain-agent)](https://packagephobia.com/result?p=@iinm/plain-agent)

@@ -6,0 +6,0 @@

@@ -1,2 +0,1 @@

import type { EventEmitter } from "node:events";
import type { AgentRole } from "./context/loadAgentRoles.mjs";

@@ -15,9 +14,17 @@ import type { CostConfig, CostSummary } from "./costTracker.mjs";

export type AgentInput = (MessageContentText | MessageContentImage)[];
export type Agent = {
userEventEmitter: UserEventEmitter;
agentEventEmitter: AgentEventEmitter;
agentCommands: AgentCommands;
};
export type AgentCommands = {
/**
* Start the agent loop and return the async event stream. Consume with
* `for await`. Also kicks off the internal input queue consumer; call
* exactly once per session.
*/
start: () => AsyncIterable<AgentEvent>;
/**
* Send user input to the agent. Input is pushed onto an internal async
* queue and consumed by the agent loop; may be called from multiple places
* (plain input, slash commands, tool approval).
*/
send: (input: AgentInput) => void;
getCostSummary: () => CostSummary;

@@ -27,24 +34,37 @@ pauseAutoApprove: () => void;

getActiveSubagent: () => { name: string } | null;
/** Wait for any pending session-state writes to flush to disk. */
flushSessionPersistence: () => Promise<void>;
};
type UserEventMap = {
userInput: [(MessageContentText | MessageContentImage)[]];
};
/**
* Discriminated union of events emitted by the agent, distinguished by the
* `type` field. Consumed via `Agent["start"]`.
*/
export type AgentEvent =
| {
type: "session_start";
sessionFormatVersion: number;
sessionId: string;
modelName: string;
workingDir: string;
startTime: string;
}
| { type: "message"; message: Message }
| { type: "partial_message_content"; partialContent: PartialMessageContent }
| { type: "error"; error: Error }
| { type: "tool_use_request"; toolUseCount: number }
| { type: "turn_end" }
| { type: "session_end"; cost: CostSummary }
| { type: "token_usage"; usage: ProviderTokenUsage }
| {
type: "subagent_switched";
subagent: {
name: string;
goal: string;
switchMessageIndex: number;
} | null;
}
| { type: "messages_reset"; messages: Message[] };
export type UserEventEmitter = EventEmitter<UserEventMap>;
/** Sink used by the agent loop to push events onto the output stream. */
export type AgentEventSink = (event: AgentEvent) => void;
type AgentEventMap = {
message: [Message];
partialMessageContent: [PartialMessageContent];
error: [Error];
toolUseRequest: [number];
turnEnd: [];
providerTokenUsage: [ProviderTokenUsage];
subagentSwitched: [{ name: string } | null];
};
export type AgentEventEmitter = EventEmitter<AgentEventMap>;
export type AgentConfig = {

@@ -51,0 +71,0 @@ callModel: CallModel;

/**
* @import { Agent, AgentConfig, AgentEventEmitter, UserEventEmitter } from "./agent"
* @import { Agent, AgentConfig, AgentEvent, AgentInput } from "./agent"
* @import { Tool, ToolDefinition } from "./tool"

@@ -7,10 +7,9 @@ * @import { CompactContextInput } from "./tools/compactContext"

* @import { SwitchToMainAgentInput } from "./tools/switchToMainAgent"
* @import { AsyncQueue } from "./utils/createAsyncQueue.mjs"
*/
import { EventEmitter } from "node:events";
import { styleText } from "node:util";
import { createAgentLoop } from "./agentLoop.mjs";
import { createStateManager } from "./agentState.mjs";
import { createCostTracker } from "./costTracker.mjs";
import { SESSION_FILE_VERSION, saveSession } from "./sessionStore.mjs";
import { SESSION_FORMAT_VERSION } from "./sessionStore.mjs";
import { createSubagentManager } from "./subagent.mjs";

@@ -24,2 +23,3 @@ import { createToolExecutor } from "./toolExecutor.mjs";

import { switchToSubagentToolName } from "./tools/switchToSubagent.mjs";
import { createAsyncQueue } from "./utils/createAsyncQueue.mjs";

@@ -42,12 +42,27 @@ /**

}) {
/** @type {UserEventEmitter} */
const userEventEmitter = new EventEmitter();
/** @type {AgentEventEmitter} */
const agentEventEmitter = new EventEmitter();
/**
* Pull-based input queue. CLI callers push input via send(); the
* agent's run loop consumes it as an async iterable.
* @type {AsyncQueue<AgentInput>}
*/
const inputQueue = createAsyncQueue();
/**
* Output stream of agent events, yielded by run().
* @type {AsyncQueue<AgentEvent>}
*/
const eventQueue = createAsyncQueue();
const costTracker = createCostTracker(modelCostConfig);
agentEventEmitter.on("providerTokenUsage", (usage) => {
costTracker.recordUsage(usage);
});
/**
* Emit an agent event onto the output stream. token_usage events are
* also recorded by the cost tracker as they pass through.
* @param {AgentEvent} event
*/
const emitEvent = (event) => {
if (event.type === "token_usage") {
costTracker.recordUsage(event.usage);
}
eventQueue.push(event);
};

@@ -68,8 +83,8 @@ // Build the initial message list. When resuming, replace messages[0] with

onMessagesAppended: (newMessages) => {
const lastMessage = newMessages.at(-1);
if (lastMessage) {
agentEventEmitter.emit("message", lastMessage);
for (const message of newMessages) {
emitEvent({ type: "message", message });
}
schedulePersist();
},
onMessagesReplaced: (messages) =>
emitEvent({ type: "messages_reset", messages }),
});

@@ -79,3 +94,3 @@

onSubagentSwitched: (subagent) => {
agentEventEmitter.emit("subagentSwitched", subagent);
emitEvent({ type: "subagent_switched", subagent });
},

@@ -89,37 +104,4 @@ });

subagentManager.restoreState(initialState.subagentState);
toolUseApprover.restoreAllowedToolUseInSession(
initialState.allowedToolUseInSession,
);
costTracker.restoreUsageHistory(initialState.tokenUsageHistory);
}
/** @type {Promise<void>} */
let persistChain = Promise.resolve();
function schedulePersist() {
persistChain = persistChain.then(async () => {
try {
await saveSession({
version: SESSION_FILE_VERSION,
sessionId: sessionMetadata.sessionId,
modelName: sessionMetadata.modelName,
workingDir: sessionMetadata.workingDir,
startTime: sessionMetadata.startTime.toISOString(),
lastUpdatedAt: new Date().toISOString(),
messages: stateManager.getMessages(),
subagentState: subagentManager.getState(),
allowedToolUseInSession: toolUseApprover.getAllowedToolUseInSession(),
tokenUsageHistory: costTracker.getUsageHistory(),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(
styleText(
"yellow",
`Warning: failed to persist session state: ${message}`,
),
);
}
});
}
/**

@@ -202,3 +184,3 @@ * @param {SwitchToSubagentInput} input

toolExecutor,
agentEventEmitter,
emitEvent,
toolUseApprover,

@@ -211,18 +193,55 @@ subagentManager,

userEventEmitter.on("userInput", agentLoop.handleUserInput);
// Drive the agent by consuming user input from the pull-based queue and
// running one turn loop per input. Runs for the lifetime of the process.
let inputLoopStarted = false;
let sessionStartEmitted = false;
const emitSessionStartOnce = () => {
if (sessionStartEmitted) return;
sessionStartEmitted = true;
emitEvent({
type: "session_start",
sessionFormatVersion: SESSION_FORMAT_VERSION,
sessionId: sessionMetadata.sessionId,
modelName: sessionMetadata.modelName,
workingDir: sessionMetadata.workingDir,
startTime: sessionMetadata.startTime.toISOString(),
});
emitEvent({
type: "messages_reset",
messages: stateManager.getMessages(),
});
};
const startInputLoop = () => {
if (inputLoopStarted) return;
inputLoopStarted = true;
(async () => {
for await (const input of inputQueue) {
await agentLoop.handleUserInput(input);
}
})().catch((err) => {
emitEvent({
type: "error",
error: err instanceof Error ? err : new Error(String(err)),
});
});
};
return {
userEventEmitter,
agentEventEmitter,
agentCommands: {
getCostSummary: () => costTracker.calculateCost(),
pauseAutoApprove: () => {
paused = true;
},
getActiveSubagent: () => subagentManager.getActiveSubagent(),
flushSessionPersistence: async () => {
await persistChain;
},
start() {
startInputLoop();
return eventQueue;
},
send(input) {
emitSessionStartOnce();
inputQueue.push(input);
},
getCostSummary: () => costTracker.calculateCost(),
pauseAutoApprove: () => {
paused = true;
},
getActiveSubagent: () => {
const active = subagentManager.getActiveSubagent();
return active ? { name: active.name } : null;
},
};
}
/**
* @import { AgentEventEmitter } from "./agent"
* @import { AgentEventSink } from "./agent"
* @import { CallModel, MessageContentText, MessageContentImage, MessageContentToolResult, PartialMessageContent, UserMessage, MessageContentToolUse } from "./model"

@@ -37,3 +37,3 @@ * @import { ToolDefinition, ToolUseApprover } from "./tool"

stateManager.setMessages([systemMessage]);
stateManager.replaceMessages([systemMessage]);
stateManager.appendMessages([

@@ -57,3 +57,3 @@ { role: "user", content: compactResult.content },

* @property {import("./toolExecutor.mjs").ToolExecutor} toolExecutor - Tool executor instance
* @property {AgentEventEmitter} agentEventEmitter - Event emitter for agent events
* @property {AgentEventSink} emitEvent - Sink that pushes agent events onto the output stream
* @property {ToolUseApprover} toolUseApprover - Tool use approval checker

@@ -79,3 +79,3 @@ * @property {SubagentManager} subagentManager - Subagent manager instance

toolExecutor,
agentEventEmitter,
emitEvent,
toolUseApprover,

@@ -104,3 +104,3 @@ subagentManager,

await runTurnLoop();
agentEventEmitter.emit("turnEnd");
emitEvent({ type: "turn_end" });
}

@@ -124,5 +124,14 @@

// Cache the prefix that survives the switch back to the main agent.
const switchMessageIndex =
subagentManager.getActiveSubagent()?.switchMessageIndex;
const additionalCacheBreakpointIndices =
switchMessageIndex !== undefined && switchMessageIndex > 0
? [switchMessageIndex - 1]
: undefined;
const modelOutput = await callModel({
messages: stateManager.getMessages(),
tools: toolDefs,
additionalCacheBreakpointIndices,
/**

@@ -132,3 +141,3 @@ * @param {PartialMessageContent} partialContent

onPartialMessageContent: (partialContent) => {
agentEventEmitter.emit("partialMessageContent", partialContent);
emitEvent({ type: "partial_message_content", partialContent });
},

@@ -138,3 +147,3 @@ });

if (modelOutput instanceof Error) {
agentEventEmitter.emit("error", modelOutput);
emitEvent({ type: "error", error: modelOutput });
break;

@@ -146,3 +155,3 @@ }

if (providerTokenUsage) {
agentEventEmitter.emit("providerTokenUsage", providerTokenUsage);
emitEvent({ type: "token_usage", usage: providerTokenUsage });
}

@@ -220,3 +229,6 @@

if (!isAllToolUseApproved) {
agentEventEmitter.emit("toolUseRequest", toolUseParts.length);
emitEvent({
type: "tool_use_request",
toolUseCount: toolUseParts.length,
});
break;

@@ -228,3 +240,6 @@ }

pauseSignal.reset();
agentEventEmitter.emit("toolUseRequest", toolUseParts.length);
emitEvent({
type: "tool_use_request",
toolUseCount: toolUseParts.length,
});
break;

@@ -269,3 +284,5 @@ }

);
stateManager.setMessages(result.messages);
if (result.state.type === "replaceMessages") {
stateManager.replaceMessages(result.state.messages);
}
if (result.newMessage) {

@@ -436,3 +453,5 @@ stateManager.appendMessages([result.newMessage]);

);
stateManager.setMessages(result.messages);
if (result.state.type === "replaceMessages") {
stateManager.replaceMessages(result.state.messages);
}

@@ -439,0 +458,0 @@ if (result.newMessage) {

@@ -12,2 +12,3 @@ /**

* @property {(messages: Message[]) => void} onMessagesAppended
* @property {(messages: Message[]) => void} [onMessagesReplaced]
*/

@@ -38,6 +39,7 @@

/** Replace all messages */
setMessages: /** @param {Message[]} newMessages */ (newMessages) => {
replaceMessages: /** @param {Message[]} newMessages */ (newMessages) => {
messages = [...newMessages];
handlers.onMessagesReplaced?.([...messages]);
},
};
}
/**
* @import { UserEventEmitter, AgentEventEmitter, AgentCommands } from "../agent"
* @import { Agent } from "../agent"
*/
import { persistSessionEvent } from "../sessionStore.mjs";
import { appendUsageRecord, buildUsageRecord } from "../usageStore.mjs";

@@ -10,5 +11,3 @@ import { formatCostForBatch } from "./formatter.mjs";

* @typedef {object} BatchSessionOptions
* @property {UserEventEmitter} userEventEmitter
* @property {AgentEventEmitter} agentEventEmitter
* @property {AgentCommands} agentCommands
* @property {Agent} agent
* @property {string} task - Task instruction to execute

@@ -30,5 +29,3 @@ * @property {string} sessionId

export async function startBatchSession({
userEventEmitter,
agentEventEmitter,
agentCommands,
agent,
task,

@@ -41,101 +38,83 @@ sessionId,

}) {
setupEventHandlers(agentEventEmitter, { sessionId, modelName, sandbox });
outputEvent({
type: "session_start",
sessionId,
modelName,
sandbox,
timestamp: new Date().toISOString(),
});
userEventEmitter.emit("userInput", [{ type: "text", text: task }]);
agent.send([{ type: "text", text: task }]);
await new Promise((/** @type {(value?: void) => void} */ resolve) => {
agentEventEmitter.on("turnEnd", async () => {
const costSummary = agentCommands.getCostSummary();
// The first turn_end marks completion of the batch task and ends the session.
for await (const event of agent.start()) {
await persistSessionEvent(sessionId, event);
switch (event.type) {
case "message":
outputEvent({
type: "message",
message: event.message,
timestamp: new Date().toISOString(),
});
break;
outputEvent({
type: "session_end",
timestamp: new Date().toISOString(),
cost: formatCostForBatch(costSummary),
});
try {
const record = buildUsageRecord({
sessionId,
mode: "batch",
modelName,
workingDir: process.cwd(),
costSummary,
now: startTime,
});
if (record) {
await appendUsageRecord(record);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
case "error":
outputEvent({
type: "error",
error: { message: `failed to record usage: ${message}` },
error: { message: event.error.message, stack: event.error.stack },
timestamp: new Date().toISOString(),
});
}
process.exit(1);
break;
await agentCommands.flushSessionPersistence();
await onStop();
resolve();
});
});
case "subagent_switched":
outputEvent({
type: "subagent_switched",
subagent: event.subagent,
timestamp: new Date().toISOString(),
});
break;
process.exit(0);
}
case "token_usage":
outputEvent({
type: "token_usage",
usage: event.usage,
timestamp: new Date().toISOString(),
});
break;
/**
* Setup event handlers for batch mode.
* Output events as JSON Lines.
*
* @param {AgentEventEmitter} agentEventEmitter
* @param {{ sessionId: string, modelName: string, sandbox: boolean }} meta
*/
function setupEventHandlers(
agentEventEmitter,
{ sessionId, modelName, sandbox },
) {
outputEvent({
type: "session_start",
sessionId,
modelName,
sandbox,
timestamp: new Date().toISOString(),
});
case "turn_end": {
const costSummary = agent.getCostSummary();
const sessionEnd = { type: "session_end", cost: costSummary };
await persistSessionEvent(sessionId, sessionEnd);
outputEvent({
type: "session_end",
timestamp: new Date().toISOString(),
cost: formatCostForBatch(costSummary),
});
agentEventEmitter.on("message", (message) => {
outputEvent({
type: "message",
message,
timestamp: new Date().toISOString(),
});
});
try {
const record = buildUsageRecord({
sessionId,
mode: "batch",
modelName,
workingDir: process.cwd(),
costSummary,
now: startTime,
});
if (record) await appendUsageRecord(record);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
outputEvent({
type: "error",
error: { message: `failed to record usage: ${message}` },
timestamp: new Date().toISOString(),
});
}
agentEventEmitter.on("error", (error) => {
outputEvent({
type: "error",
error: {
message: error.message,
stack: error.stack,
},
timestamp: new Date().toISOString(),
});
process.exit(1);
});
agentEventEmitter.on("subagentSwitched", (subagent) => {
outputEvent({
type: "subagent_switched",
subagent,
timestamp: new Date().toISOString(),
});
});
agentEventEmitter.on("providerTokenUsage", (usage) => {
outputEvent({
type: "token_usage",
usage,
timestamp: new Date().toISOString(),
});
});
await onStop();
process.exit(0);
}
}
}
}

@@ -146,3 +125,2 @@

* Each event is a single line of JSON.
*
* @param {object} event

@@ -149,0 +127,0 @@ */

/**
* @import { UserEventEmitter, AgentCommands } from "../agent"
* @import { Agent } from "../agent"
* @import { ClaudeCodePlugin } from "../claudeCodePlugin.mjs"

@@ -28,4 +28,3 @@ */

* @typedef {object} CommandHandlerDeps
* @property {AgentCommands} agentCommands
* @property {UserEventEmitter} userEventEmitter
* @property {Agent} agent
* @property {ClaudeCodePlugin[] | undefined} claudeCodePlugins

@@ -42,4 +41,3 @@ * @property {string} helpMessage

export function createCommandHandler({
agentCommands,
userEventEmitter,
agent,
claudeCodePlugins,

@@ -56,4 +54,4 @@ helpMessage,

const agentRoles = await loadAgentRoles(claudeCodePlugins);
const agent = agentRoles.get(id);
const name = agent ? id : `custom:${id}`;
const agentRole = agentRoles.get(id);
const name = agentRole ? id : `custom:${id}`;

@@ -65,6 +63,3 @@ const [goalTextContent, ...goalImages] = await loadUserMessageContext(goal);

const messageText = `Switch to "${name}" subagent with goal: ${goalText}`;
userEventEmitter.emit("userInput", [
{ type: "text", text: messageText },
...goalImages,
]);
agent.send([{ type: "text", text: messageText }, ...goalImages]);
return "continue";

@@ -103,6 +98,3 @@ }

userEventEmitter.emit("userInput", [
{ type: "text", text: message },
...argsImages,
]);
agent.send([{ type: "text", text: message }, ...argsImages]);
return "continue";

@@ -138,3 +130,3 @@ }

const messageWithContext = await loadUserMessageContext(fileContent);
userEventEmitter.emit("userInput", messageWithContext);
agent.send(messageWithContext);
return "continue";

@@ -145,3 +137,3 @@ }

if (inputTrimmed.toLowerCase() === "/cost") {
const summary = agentCommands.getCostSummary();
const summary = agent.getCostSummary();
console.log(formatCostSummary(summary));

@@ -155,5 +147,5 @@ return "prompt";

invocation: inputTrimmed,
isSubagent: agentCommands.getActiveSubagent() !== null,
isSubagent: agent.getActiveSubagent() !== null,
});
userEventEmitter.emit("userInput", [{ type: "text", text: message }]);
agent.send([{ type: "text", text: message }]);
return "continue";

@@ -261,3 +253,3 @@ }

const messageWithContext = await loadUserMessageContext(combinedInput);
userEventEmitter.emit("userInput", messageWithContext);
agent.send(messageWithContext);
return "continue";

@@ -282,5 +274,5 @@ }

const messageWithContext = await loadUserMessageContext(inputTrimmed);
userEventEmitter.emit("userInput", messageWithContext);
agent.send(messageWithContext);
return "continue";
};
}

@@ -14,3 +14,7 @@ /**

import { styleText } from "node:util";
import { parseBlocks, renderPatchBlock } from "../tools/patchFile.mjs";
import {
getPatchOriginalLines,
parseBlocks,
renderPatchBlock,
} from "../tools/patchFile.mjs";
import { noThrow } from "../utils/noThrow.mjs";

@@ -857,4 +861,7 @@

let originalLines = null;
if (filePath) {
// Prefer original lines frozen at execution time so the diff is accurate even
// if the file has already been written; fall back to reading from disk.
/** @type {string[] | import("../tools/patchFile").PatchOriginalLines | null} */
let originalLines = getPatchOriginalLines({ filePath, patch });
if (!originalLines && filePath) {
const original = await noThrow(() => fs.readFile(filePath, "utf8"));

@@ -861,0 +868,0 @@ if (!(original instanceof Error)) {

/**
* @import { UserEventEmitter, AgentEventEmitter, AgentCommands } from "../agent"
* @import { Agent } from "../agent"
* @import { ClaudeCodePlugin } from "../claudeCodePlugin.mjs"

@@ -9,4 +9,4 @@ * @import { Tool, SandboxModeProvider } from "../tool"

import { styleText } from "node:util";
import { persistSessionEvent, sessionFileExists } from "../sessionStore.mjs";
import { appendUsageRecord, buildUsageRecord } from "../usageStore.mjs";
import { createSequentialExecutor } from "../utils/createSequentialExecutor.mjs";
import { notify } from "../utils/notify.mjs";

@@ -56,5 +56,3 @@ import { createCommandHandler } from "./commands.mjs";

* @typedef {object} CliOptions
* @property {UserEventEmitter} userEventEmitter
* @property {AgentEventEmitter} agentEventEmitter
* @property {AgentCommands} agentCommands
* @property {Agent} agent
* @property {string} sessionId

@@ -101,5 +99,3 @@ * @property {string} modelName

export function startInteractiveSession({
userEventEmitter,
agentEventEmitter,
agentCommands,
agent,
sessionId,

@@ -118,3 +114,3 @@ modelName,

multiLineBuffer: null,
subagentName: agentCommands.getActiveSubagent()?.name ?? "",
subagentName: agent.getActiveSubagent()?.name ?? "",
toolSpinnerIndex: 0,

@@ -158,7 +154,18 @@ toolSpinnerLastTime: 0,

cleanup();
const summary = agentCommands.getCostSummary();
console.log();
console.log(formatCostSummary(summary));
const summary = agent.getCostSummary();
const hasSessionFile = await sessionFileExists(sessionId);
if (hasSessionFile) {
await persistSessionEvent(sessionId, {
type: "session_end",
cost: summary,
});
}
await persistUsage(summary, { sessionId, modelName, startTime });
await agentCommands.flushSessionPersistence();
console.log(
[
"",
formatCostSummary(summary),
...(hasSessionFile ? ["", `Session saved: ${sessionId}`] : []),
].join("\n"),
);
await onStop();

@@ -192,3 +199,3 @@ process.exit(0);

if (!state.turn) {
agentCommands.pauseAutoApprove();
agent.pauseAutoApprove();
console.error(

@@ -291,4 +298,3 @@ styleText(

const handleCommand = createCommandHandler({
agentCommands,
userEventEmitter,
agent,
claudeCodePlugins,

@@ -364,3 +370,7 @@ helpMessage: HELP_MESSAGE,

agentEventEmitter.on("partialMessageContent", (partialContent) => {
/**
* Render a streaming partial message content chunk.
* @param {import("../model").PartialMessageContent} partialContent
*/
const handlePartialMessageContent = (partialContent) => {
if (partialContent.position === "start") {

@@ -409,71 +419,83 @@ const subagentPrefix = state.subagentName

}
});
};
const enqueueOutput = createSequentialExecutor();
// Consume the agent's event stream. Because events are pulled one at a time
// and awaited in order, output is naturally serialized — no manual
// sequential executor is needed.
const consumeAgentEvents = async () => {
for await (const event of agent.start()) {
await persistSessionEvent(sessionId, event);
switch (event.type) {
case "partial_message_content":
handlePartialMessageContent(event.partialContent);
break;
agentEventEmitter.on("message", (message) => {
enqueueOutput(() =>
printMessage(message, { execCommandTool }).catch((err) => {
console.error(
styleText("red", `Error rendering message: ${err.message}`),
);
}),
);
});
case "message":
try {
await printMessage(event.message, { execCommandTool });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(
styleText("red", `Error rendering message: ${message}`),
);
}
break;
agentEventEmitter.on("toolUseRequest", (toolCount) => {
const toolText = toolCount === 1 ? "tool call" : "tool calls";
cli.setPrompt(
getCliPrompt(
state.subagentName,
styleText(
"yellow",
`Approve ${toolCount} ${toolText}? (y = allow once, Y = allow in this session, or feedback)`,
),
),
);
});
case "tool_use_request": {
const toolText =
event.toolUseCount === 1 ? "tool call" : "tool calls";
cli.setPrompt(
getCliPrompt(
state.subagentName,
styleText(
"yellow",
`Approve ${event.toolUseCount} ${toolText}? (y = allow once, Y = allow in this session, or feedback)`,
),
),
);
break;
}
agentEventEmitter.on("subagentSwitched", (subagent) => {
state.subagentName = subagent?.name ?? "";
currentCliPrompt = getCliPrompt(state.subagentName);
cli.setPrompt(currentCliPrompt);
});
case "subagent_switched":
state.subagentName = event.subagent?.name ?? "";
currentCliPrompt = getCliPrompt(state.subagentName);
cli.setPrompt(currentCliPrompt);
break;
agentEventEmitter.on("providerTokenUsage", (usage) => {
enqueueOutput(() => {
console.log(formatProviderTokenUsage(usage));
});
});
case "token_usage":
console.log(formatProviderTokenUsage(event.usage));
break;
agentEventEmitter.on("error", (error) => {
console.error(
styleText(
"red",
`\nError: message=${error.message}, stack=${error.stack}`,
),
);
});
case "error":
console.error(
styleText(
"red",
`\nError: message=${event.error.message}, stack=${event.error.stack}`,
),
);
break;
agentEventEmitter.on("turnEnd", async () => {
// Flush any remaining stream buffer content
streamBuffer.forceFlush();
case "turn_end": {
// Flush any remaining stream buffer content
streamBuffer.forceFlush();
const err = notify(notifyCmd);
if (err) {
console.error(
styleText("yellow", `\nNotification error: ${err.message}`),
);
}
const err = notify(notifyCmd);
if (err) {
console.error(
styleText("yellow", `\nNotification error: ${err.message}`),
);
}
// Wait for all output operations to complete
await enqueueOutput(() => {});
// Defer prompt rendering to ensure terminal output is visible
await new Promise((resolve) => setImmediate(resolve));
// Defer prompt rendering to ensure terminal output is visible
await new Promise((resolve) => setImmediate(resolve));
state.turn = true;
cli.prompt();
break;
}
}
}
};
consumeAgentEvents();
state.turn = true;
cli.prompt();
});
cli.prompt();

@@ -480,0 +502,0 @@

@@ -133,3 +133,3 @@ /**

console.log(
` ${s.sessionId} ${s.modelName} (updated ${formatLocalDateTime(s.lastUpdatedAt)}, ${s.messageCount} messages)`,
` ${s.sessionId} ${s.modelName} (updated ${formatLocalDateTime(s.lastUpdatedAt)})`,
);

@@ -222,5 +222,2 @@ if (s.workingDir !== process.cwd()) {

);
console.log(
` ⤷ ${resumedState.messages.length} messages, last updated ${formatLocalDateTime(resumedState.lastUpdatedAt)}`,
);
if (resumedState.workingDir !== process.cwd()) {

@@ -453,3 +450,3 @@ console.log(

const { userEventEmitter, agentEventEmitter, agentCommands } = createAgent({
const agent = createAgent({
callModel: agentCallModel,

@@ -473,5 +470,3 @@ prompt,

const sessionOptions = {
userEventEmitter,
agentEventEmitter,
agentCommands,
agent,
sessionId,

@@ -478,0 +473,0 @@ modelName: modelNameWithVariant,

@@ -9,2 +9,6 @@ import type { ToolDefinition } from "./tool";

onPartialMessageContent?: (partialContent: PartialMessageContent) => void;
// Extra `messages` indices to mark as cache breakpoints, on top of the
// automatic ones. Only the Anthropic provider uses this; others ignore it.
additionalCacheBreakpointIndices?: number[];
};

@@ -11,0 +15,0 @@

@@ -28,3 +28,6 @@ /**

const messages = convertGenericMessageToAnthropicFormat(input.messages);
const cacheEnabledMessages = enableContextCaching(messages);
const cacheEnabledMessages = enableContextCaching(
messages,
input.additionalCacheBreakpointIndices,
);
const tools = convertGenericToolDefinitionToAnthropicFormat(

@@ -507,26 +510,52 @@ input.tools || [],

// Anthropic allows at most 4 cache breakpoints per request.
const MAX_CACHE_BREAKPOINTS = 4;
/**
* Additional breakpoints take priority over the automatic ones; when the total
* exceeds MAX_CACHE_BREAKPOINTS, automatic breakpoints are dropped last-first.
* Omitting `additionalCacheBreakpointIndices` reproduces the automatic behavior.
* @param {AnthropicMessage[]} messages
* @param {number[]} [additionalCacheBreakpointIndices]
* @returns {AnthropicMessage[]}
*/
function enableContextCaching(messages) {
function enableContextCaching(messages, additionalCacheBreakpointIndices) {
// End of each run of consecutive user messages, so that multiple user
// messages inserted in one turn count as a single breakpoint position and
// the previous turn's cached boundary keeps a breakpoint (=> cache read hit).
/** @type {number[]} */
const userMessageIndices = [];
const userRunEndIndices = [];
for (let i = 0; i < messages.length; i++) {
if (messages[i].role === "user") {
userMessageIndices.push(i);
if (messages[i].role === "user" && messages[i + 1]?.role !== "user") {
userRunEndIndices.push(i);
}
}
const cacheTargetIndices = [
// last user message
userMessageIndices.at(-1),
// second last user message
userMessageIndices.at(-2),
// Ordered by priority (most valuable first).
const autoTargetIndices = [
// last user run
userRunEndIndices.at(-1),
// second last user run
userRunEndIndices.at(-2),
// system prompt
messages[0]?.role === "system" ? 0 : undefined,
].filter((index) => index !== undefined);
const additionalTargetIndices = (
additionalCacheBreakpointIndices ?? []
).filter(
(index) => Number.isInteger(index) && index >= 0 && index < messages.length,
);
/** @type {Set<number>} */
const cacheTargetIndices = new Set();
for (const index of [...additionalTargetIndices, ...autoTargetIndices]) {
if (cacheTargetIndices.size >= MAX_CACHE_BREAKPOINTS) {
break;
}
cacheTargetIndices.add(index);
}
const contextCachingEnabledMessages = messages.map((message, index) => {
if (
(index === 0 && message.role === "system") ||
cacheTargetIndices.includes(index)
) {
if (cacheTargetIndices.has(index)) {
return {

@@ -533,0 +562,0 @@ ...message,

/**
* @import { Message, ProviderTokenUsage } from "./model"
* @import { ToolUsePattern } from "./tool"
*/

@@ -10,5 +9,15 @@

/** Current on-disk format version. Bump on breaking changes. */
export const SESSION_FILE_VERSION = 1;
/** Current on-disk event-stream format version. Bump on breaking changes. */
export const SESSION_FORMAT_VERSION = 2;
/** Event types that are persisted in session JSONL streams. */
const WRITABLE_EVENT_TYPES = new Set([
"session_start",
"message",
"token_usage",
"subagent_switched",
"messages_reset",
"session_end",
]);
/**

@@ -27,6 +36,4 @@ * @typedef {Object} SubagentSerializedState

* @property {string} startTime - ISO 8601
* @property {string} lastUpdatedAt - ISO 8601
* @property {Message[]} messages
* @property {SubagentSerializedState} subagentState
* @property {ToolUsePattern[]} allowedToolUseInSession
* @property {ProviderTokenUsage[]} tokenUsageHistory

@@ -42,7 +49,6 @@ */

* @property {string} lastUpdatedAt
* @property {number} messageCount
*/
/**
* Resolve the path to a session file.
* Resolve the path to a session event stream.
* @param {string} sessionId

@@ -53,30 +59,47 @@ * @param {{ dir?: string }} [options]

const dir = options.dir ?? SESSIONS_DIR;
return path.join(dir, `${sessionId}.json`);
return path.join(dir, `${sessionId}.jsonl`);
}
/**
* Persist a session state atomically.
*
* Writes to a process-unique temp file in the same directory, then renames
* it over the target path. Same-directory rename is atomic on POSIX, so a
* crash during write leaves either the previous file or the new one — never
* a half-written file.
*
* @param {SessionState} state
* Return whether a session event-stream file exists.
* @param {string} sessionId
* @param {{ dir?: string }} [options]
* @returns {Promise<void>}
* @returns {Promise<boolean>}
*/
export async function saveSession(state, options = {}) {
export async function sessionFileExists(sessionId, options = {}) {
try {
await fs.access(sessionFilePath(sessionId, options));
return true;
} catch (err) {
if (
err instanceof Error &&
/** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT"
) {
return false;
}
throw err;
}
}
/**
* Persist an event when it belongs in the session event stream.
* @param {string} sessionId
* @param {{ type: string, [key: string]: unknown }} event
* @param {{ dir?: string }} [options]
*/
export async function persistSessionEvent(sessionId, event, options = {}) {
if (!WRITABLE_EVENT_TYPES.has(event.type)) return;
const dir = options.dir ?? SESSIONS_DIR;
await fs.mkdir(dir, { recursive: true });
const target = path.join(dir, `${state.sessionId}.json`);
const tmp = `${target}.tmp.${process.pid}`;
const json = JSON.stringify(state, null, 2);
await fs.writeFile(tmp, json, "utf8");
await fs.rename(tmp, target);
const line = JSON.stringify({
...event,
timestamp: new Date().toISOString(),
});
await fs.appendFile(sessionFilePath(sessionId, { dir }), `${line}\n`, "utf8");
}
/**
* Load a session by id. Returns null when the file does not exist.
* Throws on parse errors or unsupported versions.
* Load a session by replaying its JSONL event stream. Returns null when the
* file does not exist. Corrupt event lines are ignored.
*

@@ -89,4 +112,3 @@ * @param {string} sessionId

const dir = options.dir ?? SESSIONS_DIR;
const target = path.join(dir, `${sessionId}.json`);
/** @type {string} */
const target = sessionFilePath(sessionId, { dir });
let raw;

@@ -105,22 +127,75 @@ try {

const parsed = JSON.parse(raw);
if (
typeof parsed !== "object" ||
parsed === null ||
typeof parsed.version !== "number"
) {
const lines = raw.split("\n");
/** @type {unknown} */
let firstEvent;
for (const line of lines) {
if (!line) continue;
try {
firstEvent = JSON.parse(line);
} catch {
throw new Error(`Invalid session file: ${target}`);
}
break;
}
if (!isSessionStart(firstEvent)) {
throw new Error(`Invalid session file: ${target}`);
}
if (parsed.version !== SESSION_FILE_VERSION) {
if (firstEvent.sessionFormatVersion !== SESSION_FORMAT_VERSION) {
throw new Error(
`Unsupported session file version ${parsed.version} at ${target} (expected ${SESSION_FILE_VERSION})`,
`Unsupported session file version ${firstEvent.sessionFormatVersion} at ${target} (expected ${SESSION_FORMAT_VERSION})`,
);
}
return /** @type {SessionState} */ (parsed);
/** @type {Message[]} */
let messages = [];
/** @type {ProviderTokenUsage[]} */
const tokenUsageHistory = [];
/** @type {{name: string, goal: string, switchMessageIndex: number}[]} */
const subagents = [];
let subagentCount = 0;
for (const line of lines.slice(1)) {
if (!line) continue;
let event;
try {
event = JSON.parse(line);
} catch {
continue;
}
if (!event || typeof event !== "object") continue;
switch (event.type) {
case "message":
if (event.message) messages.push(event.message);
break;
case "messages_reset":
if (Array.isArray(event.messages)) messages = [...event.messages];
break;
case "token_usage":
if (event.usage) tokenUsageHistory.push(event.usage);
break;
case "subagent_switched":
if (event.subagent && isSubagent(event.subagent)) {
subagents.push(event.subagent);
subagentCount++;
} else if (event.subagent === null) {
subagents.pop();
}
break;
}
}
return {
version: SESSION_FORMAT_VERSION,
sessionId: firstEvent.sessionId,
modelName: firstEvent.modelName,
workingDir: firstEvent.workingDir,
startTime: firstEvent.startTime,
messages,
subagentState: { subagents, subagentCount },
tokenUsageHistory,
};
}
/**
* List sessions in the sessions directory, sorted by lastUpdatedAt descending.
* Malformed files are silently skipped.
*
* List valid session event streams, sorted by most recently modified first.
* @param {{ dir?: string }} [options]

@@ -131,3 +206,2 @@ * @returns {Promise<SessionSummary[]>}

const dir = options.dir ?? SESSIONS_DIR;
/** @type {string[]} */
let entries;

@@ -149,24 +223,64 @@ try {

for (const name of entries) {
if (!name.endsWith(".json")) continue;
if (name.includes(".tmp.")) continue;
const sessionId = name.slice(0, -".json".length);
if (!name.endsWith(".jsonl")) continue;
try {
const state = await loadSession(sessionId, { dir });
if (!state) continue;
const target = path.join(dir, name);
const firstLine = (await fs.readFile(target, "utf8")).split("\n", 1)[0];
const event = JSON.parse(firstLine);
if (
!isSessionStart(event) ||
event.sessionFormatVersion !== SESSION_FORMAT_VERSION
)
continue;
const stat = await fs.stat(target);
summaries.push({
sessionId: state.sessionId,
modelName: state.modelName,
workingDir: state.workingDir,
startTime: state.startTime,
lastUpdatedAt: state.lastUpdatedAt,
messageCount: state.messages.length,
sessionId: event.sessionId,
modelName: event.modelName,
workingDir: event.workingDir,
startTime: event.startTime,
lastUpdatedAt: stat.mtime.toISOString(),
});
} catch {
// Skip malformed or version-mismatched files so a single bad file
// doesn't break listing.
// A malformed stream must not prevent listing other sessions.
}
}
summaries.sort((a, b) => b.lastUpdatedAt.localeCompare(a.lastUpdatedAt));
return summaries;
}
/**
* @param {unknown} event
* @returns {event is {
* type: "session_start",
* sessionFormatVersion: number,
* sessionId: string,
* modelName: string,
* workingDir: string,
* startTime: string,
* }}
*/
function isSessionStart(event) {
if (!event || typeof event !== "object") return false;
const candidate = /** @type {Record<string, unknown>} */ (event);
return (
candidate.type === "session_start" &&
typeof candidate.sessionFormatVersion === "number" &&
typeof candidate.sessionId === "string" &&
typeof candidate.modelName === "string" &&
typeof candidate.workingDir === "string" &&
typeof candidate.startTime === "string"
);
}
/**
* @param {unknown} subagent
* @returns {subagent is {name: string, goal: string, switchMessageIndex: number}}
*/
function isSubagent(subagent) {
if (!subagent || typeof subagent !== "object") return false;
const candidate = /** @type {Record<string, unknown>} */ (subagent);
return (
typeof candidate.name === "string" &&
typeof candidate.goal === "string" &&
typeof candidate.switchMessageIndex === "number"
);
}

@@ -17,3 +17,3 @@ /**

* @typedef {Object} SubagentStateEventHandlers
* @property {(subagent: {name:string} | null) => void} onSubagentSwitched
* @property {(subagent: {name:string, goal:string, switchMessageIndex:number} | null) => void} onSubagentSwitched
*/

@@ -92,3 +92,3 @@

});
handlers.onSubagentSwitched({ name: actualName });
handlers.onSubagentSwitched({ name: actualName, goal, switchMessageIndex });

@@ -166,8 +166,7 @@ return {

* Process tool results and update state based on special tools.
* Returns the truncated message history and a new message to add.
* @param {MessageContentToolUse[]} toolUseParts
* @param {MessageContentToolResult[]} toolResults
* @param {Message[]} messages
* @returns {{ messages: Message[], newMessage: Message | null }}
* - messages: The potentially truncated message history (new array)
* @returns {{ state: { type: "unchanged" } | { type: "replaceMessages", messages: Message[] }, newMessage: Message | null }}
* - state: Whether the caller must replace its messages
* - newMessage: The user message to add, or null if tool results should be added directly

@@ -185,3 +184,3 @@ */

if (!reportResult) {
return { messages, newMessage: null };
return { state: { type: "unchanged" }, newMessage: null };
}

@@ -195,3 +194,3 @@ return handleSubagentReport(

return { messages, newMessage: null };
return { state: { type: "unchanged" }, newMessage: null };
}

@@ -206,9 +205,7 @@

* @param {Message[]} messages
* @returns {{ messages: Message[], newMessage: Message | null }}
* - messages: The truncated message history (new array)
* - newMessage: The user message to add, or null if not handled
* @returns {{ state: { type: "unchanged" } | { type: "replaceMessages", messages: Message[] }, newMessage: Message | null }}
*/
function handleSubagentReport(reportToolUse, reportResult, messages) {
if (reportResult.isError) {
return { messages, newMessage: null };
return { state: { type: "unchanged" }, newMessage: null };
}

@@ -218,3 +215,3 @@

if (!currentSubagent) {
return { messages, newMessage: null };
return { state: { type: "unchanged" }, newMessage: null };
}

@@ -255,3 +252,6 @@

return { messages: truncatedMessages, newMessage };
return {
state: { type: "replaceMessages", messages: truncatedMessages },
newMessage,
};
}

@@ -269,7 +269,9 @@

* Get the most recently activated subagent, or null if none is active.
* @returns {{name: string} | null}
* @returns {{name: string, switchMessageIndex: number} | null}
*/
function getActiveSubagent() {
const top = subagents.at(-1);
return top ? { name: top.name } : null;
return top
? { name: top.name, switchMessageIndex: top.switchMessageIndex }
: null;
}

@@ -276,0 +278,0 @@

@@ -80,4 +80,2 @@ import type { MessageContentToolUse } from "./model";

resetApprovalCount: () => void;
getAllowedToolUseInSession: () => ToolUsePattern[];
restoreAllowedToolUseInSession: (patterns: ToolUsePattern[]) => void;
};

@@ -84,0 +82,0 @@

@@ -6,2 +6,13 @@ export type PatchFileInput = {

/**
* Original lines captured before applying a patch. Only the lines needed to
* render the diff (the union of replace blocks' ranges) are stored, keyed by
* their 1-based line number, alongside the file's total line count so callers
* can compute end bounds.
*/
export type PatchOriginalLines = {
totalLines: number;
lines: Record<number, string>;
};
export type PatchBlock =

@@ -8,0 +19,0 @@ | {

/**
* @import { Tool } from '../tool'
* @import { PatchBlock, PatchFileInput } from './patchFile'
* @import { PatchBlock, PatchFileInput, PatchOriginalLines } from './patchFile'
*/
import { createHash } from "node:crypto";
import fs from "node:fs/promises";

@@ -11,3 +12,13 @@ import { diffLines } from "../utils/diffLines.mjs";

/** Maximum entries in the original-lines cache. */
const MAX_PATCH_ORIGINAL_LINES_CACHE_ENTRIES = 32;
/**
* Original lines captured before applying a patch, keyed by patch input.
*
* @type {Map<string, PatchOriginalLines>}
*/
const patchOriginalLinesCache = new Map();
/**
* @param {string} [nonce]

@@ -71,2 +82,11 @@ * @returns {Tool}

const original = await fs.readFile(filePath, "utf8");
// Freeze the original lines for the diff preview here, before the
// write, so the CLI can render an accurate diff no matter when it runs.
setPatchOriginalLines(
input,
buildPatchOriginalLines(
splitFileLines(original),
collectPatchLineRanges(blocks),
),
);
const newContent = applyBlocks(original, blocks);

@@ -254,4 +274,7 @@ await fs.writeFile(filePath, newContent);

*
* The original content may be provided either as an absolute array of every
* line (backward-compatible), or as sparse {@link PatchOriginalLines}.
*
* @param {PatchBlock} block
* @param {string[] | null} originalLines
* @param {string[] | PatchOriginalLines | null} originalLines
* @param {string} nonce

@@ -266,2 +289,4 @@ * @param {{ header?: DiffStyler, del?: DiffStyler, add?: DiffStyler }} [style]

const source = normalizeOriginalSource(originalLines);
/** @type {string[]} */

@@ -275,6 +300,10 @@ const out = [];

);
if (originalLines) {
if (source) {
const safeStart = Math.max(1, block.start);
const safeEnd = Math.min(originalLines.length, block.end);
const oldSlice = originalLines.slice(safeStart - 1, safeEnd);
const safeEnd = Math.min(source.totalLines, block.end);
/** @type {string[]} */
const oldSlice = [];
for (let i = safeStart; i <= safeEnd; i++) {
oldSlice.push(source.getLine(i));
}
// Use a real line diff so unchanged lines render as context

@@ -310,2 +339,55 @@ // (no color, " " prefix) instead of being shown as both "- " and

/**
* Compute a deterministic cache key for a patch input.
*
* @param {PatchFileInput} input
* @returns {string}
*/
function patchOriginalLinesCacheKey(input) {
const { filePath, patch } = input;
const serialized = JSON.stringify({ filePath, patch });
return createHash("sha256").update(serialized).digest("hex");
}
/**
* Compute the set of original line ranges needed to render a patch's diff
* preview: the union of every replace block's `[start, end]` (1-based,
* inclusive). Insert blocks reference no original content and are excluded.
* Overlapping or adjacent ranges are merged; the result is sorted ascending.
*
* @param {PatchBlock[]} blocks
* @returns {[number, number][]}
*/
export function collectPatchLineRanges(blocks) {
/** @type {[number, number][]} */
const ranges = [];
for (const block of blocks) {
if (block.op === "replace") {
ranges.push([block.start, block.end]);
}
}
ranges.sort((a, b) => a[0] - b[0]);
/** @type {[number, number][]} */
const merged = [];
for (const [start, end] of ranges) {
const last = merged[merged.length - 1];
if (last && start <= last[1] + 1) {
last[1] = Math.max(last[1], end);
} else {
merged.push([start, end]);
}
}
return merged;
}
/**
* Look up original lines captured before applying a patch.
*
* @param {PatchFileInput} input
* @returns {PatchOriginalLines | null}
*/
export function getPatchOriginalLines(input) {
return patchOriginalLinesCache.get(patchOriginalLinesCacheKey(input)) ?? null;
}
/**
* @param {string} headerArgs

@@ -450,1 +532,90 @@ * @param {"replace" | "insert"} op

}
/**
* Store original lines using an opaque cache key derived from the patch input,
* maintaining the cache as an LRU: re-inserting an existing key moves it to
* newest, and once the entry count exceeds
* {@link MAX_PATCH_ORIGINAL_LINES_CACHE_ENTRIES} the oldest entries are
* evicted.
*
* @param {PatchFileInput} input
* @param {PatchOriginalLines} originalLines
*/
function setPatchOriginalLines(input, originalLines) {
const key = patchOriginalLinesCacheKey(input);
if (patchOriginalLinesCache.has(key)) {
patchOriginalLinesCache.delete(key);
}
patchOriginalLinesCache.set(key, originalLines);
while (
patchOriginalLinesCache.size > MAX_PATCH_ORIGINAL_LINES_CACHE_ENTRIES
) {
const oldest = patchOriginalLinesCache.keys().next().value;
if (oldest === undefined) {
break;
}
patchOriginalLinesCache.delete(oldest);
}
}
/**
* Build sparse original lines from an absolute array, keeping only the lines
* within the given ranges (1-based, inclusive) plus the file's total line
* count.
*
* @param {string[]} originalLines
* @param {[number, number][]} ranges
* @returns {PatchOriginalLines}
*/
function buildPatchOriginalLines(originalLines, ranges) {
/** @type {Record<number, string>} */
const lines = {};
for (const [start, end] of ranges) {
const safeStart = Math.max(1, start);
const safeEnd = Math.min(originalLines.length, end);
for (let i = safeStart; i <= safeEnd; i++) {
lines[i] = originalLines[i - 1];
}
}
return { totalLines: originalLines.length, lines };
}
/**
* Normalize the original-content argument of {@link renderPatchBlock} into a
* uniform accessor, accepting either an absolute line array or sparse original
* lines. Returns null when no content is available.
*
* @param {string[] | PatchOriginalLines | null} originalLines
* @returns {{ totalLines: number; getLine: (line: number) => string } | null}
*/
function normalizeOriginalSource(originalLines) {
if (!originalLines) {
return null;
}
if (Array.isArray(originalLines)) {
return {
totalLines: originalLines.length,
getLine: (line) => originalLines[line - 1] ?? "",
};
}
return {
totalLines: originalLines.totalLines,
getLine: (line) => originalLines.lines[line] ?? "",
};
}
/**
* Split file content into 1-based lines, dropping the trailing empty element
* produced when the content ends with a newline. Mirrors {@link applyBlocks}'s
* own line indexing so snapshot line numbers match read_file / patch offsets.
*
* @param {string} content
* @returns {string[]}
*/
function splitFileLines(content) {
const lines = content.split("\n");
if (lines.length > 0 && lines[lines.length - 1] === "") {
lines.pop();
}
return lines;
}

@@ -97,26 +97,2 @@ /**

}
/**
* Snapshot the tool-use patterns the user explicitly allowed during this
* session. Used to persist resumable session state.
* @returns {ToolUsePattern[]}
*/
function getAllowedToolUseInSession() {
return state.allowedToolUseInSession.map((p) => ({ ...p }));
}
/**
* Replace the in-session allow-list with a previously saved snapshot.
* @param {ToolUsePattern[]} patterns
*/
function restoreAllowedToolUseInSession(patterns) {
if (!Array.isArray(patterns)) {
throw new TypeError("patterns must be an array");
}
state.allowedToolUseInSession.length = 0;
for (const p of patterns) {
state.allowedToolUseInSession.push({ ...p });
}
}
return {

@@ -126,5 +102,3 @@ isAllowedToolUse,

resetApprovalCount,
getAllowedToolUseInSession,
restoreAllowedToolUseInSession,
};
}
/**
* Create a sequential executor that enqueues async operations
* and executes them in order.
*
* @returns {(fn: () => Promise<void> | void) => Promise<void>}
* A function that enqueues an operation and returns a promise
* that resolves when all queued operations (including this one) complete.
*
* @example
* const enqueue = createSequentialExecutor();
*
* // These will execute in order, even if called from different event handlers
* enqueue(() => console.log("first"));
* enqueue(async () => { await something(); });
* enqueue(() => console.log("second"));
*/
export function createSequentialExecutor() {
/** @type {Promise<void>} */
let chain = Promise.resolve();
return (fn) => {
chain = chain.then(fn).catch((err) => {
// Prevent unhandled rejection, but log the error
console.error("Sequential executor error:", err);
});
return chain;
};
}