@iinm/plain-agent
Advanced tools
| /** | ||
| * @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", |
+1
-1
| # Plain Agent | ||
| [](https://github.com/iinm/plain-agent/actions/workflows/github-code-scanning/codeql) | ||
| [](https://socket.dev/npm/package/@iinm/plain-agent) | ||
| [](https://socket.dev/npm/package/@iinm/plain-agent) | ||
| [](https://packagephobia.com/result?p=@iinm/plain-agent) | ||
@@ -6,0 +6,0 @@ |
+45
-25
@@ -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; |
+81
-62
| /** | ||
| * @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; | ||
| }, | ||
| }; | ||
| } |
+31
-12
| /** | ||
| * @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]); | ||
| }, | ||
| }; | ||
| } |
+73
-95
| /** | ||
| * @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 @@ */ |
+13
-21
| /** | ||
| * @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)) { |
+97
-75
| /** | ||
| * @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 @@ |
+3
-8
@@ -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, |
+4
-0
@@ -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, |
+168
-54
| /** | ||
| * @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
-15
@@ -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 @@ |
+0
-2
@@ -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 @@ | { |
+176
-5
| /** | ||
| * @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; | ||
| }; | ||
| } |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
603772
2.18%17128
2.39%