🎩 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
127
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.15.3
to
1.15.4
+239
src/metrics/costTracker.mjs
/**
* @import { ProviderTokenUsage } from "../model"
*/
/**
* @typedef {Object} TokenBreakdown
* @property {number} tokens - Token count
* @property {number | undefined} cost - Cost (undefined if no pricing)
*/
/**
* @typedef {Object} CostSummary
* @property {string} currency - Currency code (e.g., "USD")
* @property {string} unit - Unit size (e.g., "1M")
* @property {Record<string, TokenBreakdown>} breakdown - Token breakdown
* @property {number | undefined} totalCost - Total cost (undefined if no pricing)
*/
/**
* @typedef {Object} CostConfig
* @property {string} currency
* @property {string} unit
* @property {Record<string, number>} prices
*/
/**
* @typedef {Object} CostTracker
* @property {(usage: ProviderTokenUsage) => void} recordUsage - Record token usage
* @property {() => Record<string, number>} getAggregatedUsage - Get aggregated usage
* @property {() => CostSummary} calculateCost - Calculate cost summary
* @property {() => boolean} hasUsage - Check if any usage recorded
* @property {() => ProviderTokenUsage[]} getUsageHistory - Get a snapshot of the raw usage history
* @property {(history: ProviderTokenUsage[]) => void} restoreUsageHistory - Replace the usage history (used when resuming a saved session)
*/
/**
* Validate a cost configuration object at runtime.
* @param {unknown} config
*/
function validateCostConfig(config) {
if (config === undefined) return;
if (typeof config !== "object" || config === null) {
throw new TypeError("CostConfig must be an object");
}
const c = /** @type {Record<string, unknown>} */ (config);
if (typeof c.currency !== "string") {
throw new TypeError("CostConfig.currency must be a string");
}
if (typeof c.unit !== "string") {
throw new TypeError("CostConfig.unit must be a string");
}
if (typeof c.prices !== "object" || c.prices === null) {
throw new TypeError("CostConfig.prices must be an object");
}
for (const [key, value] of Object.entries(
/** @type {Record<string, unknown>} */ (c.prices),
)) {
if (typeof value !== "number") {
throw new TypeError(
`CostConfig.prices["${key}"] must be a number, got ${typeof value}`,
);
}
}
}
/**
* Create a cost tracker for session token usage
* @param {CostConfig} [costConfig] - Optional cost configuration
* @returns {CostTracker}
*/
export function createCostTracker(costConfig) {
validateCostConfig(costConfig);
/** @type {ProviderTokenUsage[]} */
const usageHistory = [];
/**
* Record token usage from a provider.
* Throws when usage is not a non-null object.
* @param {ProviderTokenUsage} usage
* @throws {TypeError} when usage is null, undefined, or not an object
*/
function recordUsage(usage) {
if (typeof usage !== "object" || usage === null) {
throw new TypeError("usage must be a non-null object");
}
usageHistory.push(usage);
}
/**
* Get aggregated token usage
* @returns {Record<string, number>}
*/
function getAggregatedUsage() {
return aggregateTokens(usageHistory);
}
/**
* Calculate cost summary
* @returns {CostSummary}
*/
function calculateCost() {
const aggregated = aggregateTokens(usageHistory);
return calculateCostFromConfig(aggregated, costConfig);
}
/**
* Check if any usage recorded
* @returns {boolean}
*/
function hasUsage() {
return usageHistory.length > 0;
}
/**
* Get a snapshot copy of the raw usage history.
* @returns {ProviderTokenUsage[]}
*/
function getUsageHistory() {
return usageHistory.map((u) => u);
}
/**
* Replace the usage history. Used when resuming a saved session.
* @param {ProviderTokenUsage[]} history
*/
function restoreUsageHistory(history) {
if (!Array.isArray(history)) {
throw new TypeError("history must be an array");
}
usageHistory.length = 0;
for (const usage of history) {
if (typeof usage !== "object" || usage === null) {
throw new TypeError("each usage entry must be a non-null object");
}
usageHistory.push(usage);
}
}
return Object.freeze({
recordUsage,
getAggregatedUsage,
calculateCost,
hasUsage,
getUsageHistory,
restoreUsageHistory,
});
}
/**
* Aggregate token usage history by key
* @param {ProviderTokenUsage[]} usageHistory
* @returns {Record<string, number>}
*/
function aggregateTokens(usageHistory) {
/** @type {Record<string, number>} */
const aggregated = {};
for (const usage of usageHistory) {
recursivelySumValues(usage, [], aggregated);
}
return aggregated;
}
/**
* Recursively sum numeric values in token usage
* @param {ProviderTokenUsage} obj
* @param {string[]} path
* @param {Record<string, number>} result
*/
function recursivelySumValues(obj, path, result) {
for (const [key, value] of Object.entries(obj)) {
const currentPath = [...path, key];
const pathStr = currentPath.join(".");
if (typeof value === "number") {
result[pathStr] = (result[pathStr] || 0) + value;
} else if (
typeof value === "object" &&
value !== null &&
!Array.isArray(value)
) {
recursivelySumValues(value, currentPath, result);
}
}
}
/**
* Calculate cost from aggregated tokens and config
* @param {Record<string, number>} aggregated
* @param {CostConfig | undefined} config
* @returns {CostSummary}
*/
function calculateCostFromConfig(aggregated, config) {
/** @type {Record<string, TokenBreakdown>} */
const breakdown = {};
let totalCost = 0;
for (const [key, tokens] of Object.entries(aggregated)) {
breakdown[key] = Object.freeze({ tokens, cost: undefined });
if (!config?.prices?.[key]) {
continue;
}
const costValue = config.prices[key];
const unitSize = parseUnit(config.unit);
if (typeof costValue !== "number") {
throw new TypeError(
`config.prices["${key}"] must be a number, got ${typeof costValue}`,
);
}
const cost = (tokens * costValue) / unitSize;
breakdown[key] = Object.freeze({ tokens, cost });
totalCost += cost;
}
return Object.freeze({
currency: config?.currency ?? "USD",
unit: config?.unit ?? "1M",
totalCost: config?.prices ? totalCost : undefined,
breakdown,
});
}
/**
* Parse unit string to number.
* @param {string} unit
* @returns {number}
* @throws {Error} when the unit is not recognized
*/
function parseUnit(unit) {
if (unit === "1M") return 1_000_000;
if (unit === "1K") return 1_000;
throw new Error(`Unknown cost unit: "${unit}"`);
}
/**
* @import { CostSummary } from "./costTracker.mjs"
*/
import fs from "node:fs/promises";
import path from "node:path";
import { USAGE_LOG_PATH } from "../env.mjs";
/**
* @typedef {Object} UsageRecord
* @property {string} timestamp - ISO 8601 timestamp
* @property {string} sessionId
* @property {"interactive" | "batch"} mode
* @property {string} modelName - e.g. "claude-sonnet-4-6+thinking-high"
* @property {string} workingDir
* @property {string} currency - e.g. "USD"
* @property {string} unit - e.g. "1M"
* @property {number | null} totalCost - null when no pricing configured
* @property {Record<string, number>} tokens - aggregated token counts by path
*/
/**
* Maximum size (in bytes) of a single JSONL line.
* Linux guarantees atomicity of O_APPEND writes up to PIPE_BUF (4096 bytes),
* so we enforce a smaller limit to stay safely under that threshold even
* with multi-byte UTF-8 characters in model/session names.
*/
const MAX_RECORD_BYTES = 3072;
/**
* Append a usage record to the persistent usage log.
*
* On POSIX systems, `fs.appendFile` opens the file with `O_APPEND`, which
* guarantees that each write lands at end-of-file and is atomic when the
* payload is <= PIPE_BUF (4096 bytes on Linux). We write the record as a
* single call to preserve this guarantee even if multiple plain-agent
* sessions finish simultaneously.
*
* @param {UsageRecord} record
* @param {{ path?: string }} [options]
* @returns {Promise<void | Error>}
*/
export async function appendUsageRecord(record, options = {}) {
const filePath = options.path ?? USAGE_LOG_PATH;
const line = `${JSON.stringify(record)}\n`;
const bytes = Buffer.byteLength(line, "utf8");
if (bytes > MAX_RECORD_BYTES) {
return new Error(
`Usage record exceeds ${MAX_RECORD_BYTES} bytes (${bytes}); refusing to write to keep appends atomic.`,
);
}
try {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.appendFile(filePath, line, { encoding: "utf8" });
} catch (err) {
if (err instanceof Error) return err;
}
}
/**
* Read all usage records from the log file.
* Malformed lines are skipped and collected in `skipped`.
*
* @param {{ path?: string }} [options]
* @returns {Promise<{ records: UsageRecord[], skipped: { line: number, reason: string }[] }>}
*/
export async function readUsageRecords(options = {}) {
const filePath = options.path ?? USAGE_LOG_PATH;
/** @type {string} */
let content;
try {
content = await fs.readFile(filePath, "utf8");
} catch (err) {
if (
err instanceof Error &&
/** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT"
) {
return { records: [], skipped: [] };
}
throw err;
}
/** @type {UsageRecord[]} */
const records = [];
/** @type {{ line: number, reason: string }[]} */
const skipped = [];
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const raw = lines[i];
if (raw.length === 0) continue;
try {
const parsed = JSON.parse(raw);
if (!isUsageRecord(parsed)) {
skipped.push({ line: i + 1, reason: "invalid shape" });
continue;
}
records.push(parsed);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
skipped.push({ line: i + 1, reason });
}
}
return { records, skipped };
}
/**
* Build a usage record from a finished session's cost summary.
* Returns null when there's nothing worth recording (no tokens).
*
* @param {Object} args
* @param {string} args.sessionId
* @param {"interactive" | "batch"} args.mode
* @param {string} args.modelName
* @param {string} args.workingDir
* @param {CostSummary} args.costSummary
* @param {Date} [args.now]
* @returns {UsageRecord | null}
*/
export function buildUsageRecord({
sessionId,
mode,
modelName,
workingDir,
costSummary,
now,
}) {
/** @type {Record<string, number>} */
const tokens = {};
for (const [key, entry] of Object.entries(costSummary.breakdown)) {
tokens[key] = entry.tokens;
}
if (Object.keys(tokens).length === 0) {
return null;
}
const timestamp = (now ?? new Date()).toISOString();
return {
timestamp,
sessionId,
mode,
modelName,
workingDir,
currency: costSummary.currency,
unit: costSummary.unit,
totalCost:
costSummary.totalCost === undefined ? null : costSummary.totalCost,
tokens,
};
}
/**
* @param {unknown} value
* @returns {value is UsageRecord}
*/
function isUsageRecord(value) {
if (typeof value !== "object" || value === null) return false;
const r = /** @type {Record<string, unknown>} */ (value);
return (
typeof r.timestamp === "string" &&
typeof r.sessionId === "string" &&
(r.mode === "interactive" || r.mode === "batch") &&
typeof r.modelName === "string" &&
typeof r.workingDir === "string" &&
typeof r.currency === "string" &&
typeof r.unit === "string" &&
(r.totalCost === null || typeof r.totalCost === "number") &&
typeof r.tokens === "object" &&
r.tokens !== null
);
}
import { AnthropicModelConfig } from "./providers/anthropic";
import { BedrockConverseModelConfig } from "./providers/bedrock";
import { GeminiModelConfig } from "./providers/gemini";
import { OpenAIModelConfig } from "./providers/openai";
import { OpenAICompatibleModelConfig } from "./providers/openaiCompatible";
export type ModelDefinition = {
name: string;
variant: string;
platform: PlatformConfig;
model: ModelConfig;
cost?: CostConfig;
autoCompact?: ModelAutoCompactConfig;
};
export type ModelAutoCompactConfig = {
/** Keys in providerTokenUsage whose values are summed to get the input token count. */
inputTokensKeys: string[];
};
export type PlatformConfig =
| {
name: "anthropic";
variant: string;
baseURL: string;
customHeaders?: Record<string, string>;
apiKey: string;
}
| {
name: "gemini";
variant: string;
baseURL: string;
customHeaders?: Record<string, string>;
apiKey: string;
}
| {
name: "openai";
variant: string;
baseURL: string;
customHeaders?: Record<string, string>;
apiKey: string;
}
| {
name: "openai-compatible";
variant: string;
baseURL: string;
customHeaders?: Record<string, string>;
apiKey: string;
}
| {
name: "azure";
variant: string;
baseURL: string;
customHeaders?: Record<string, string>;
azureConfigDir?: string;
}
| {
name: "bedrock";
variant: string;
baseURL: string;
customHeaders?: Record<string, string>;
awsProfile: string;
}
| {
name: "vertex-ai";
variant: string;
baseURL: string;
customHeaders?: Record<string, string>;
account?: string;
};
export type ModelConfig =
| {
format: "anthropic";
config: AnthropicModelConfig;
}
| {
format: "gemini";
config: GeminiModelConfig;
}
| {
format: "openai-responses";
config: OpenAIModelConfig;
}
| {
format: "openai-messages";
config: OpenAICompatibleModelConfig;
}
| {
format: "bedrock-converse";
config: BedrockConverseModelConfig;
};
export type CostConfig = {
currency: string;
unit: string;
prices: Record<string, number>;
};
import { callAnthropicModel } from "./providers/anthropic.mjs";
import { callBedrockConverseModel } from "./providers/bedrock.mjs";
import { createCacheEnabledGeminiModelCaller } from "./providers/gemini.mjs";
import { callOpenAIModel } from "./providers/openai.mjs";
import { callOpenAICompatibleModel } from "./providers/openaiCompatible.mjs";
/**
* @param {import("./model.definition").ModelDefinition} modelDef
* @returns {import("./model").CallModel}
*/
export function createModelCaller(modelDef) {
const { platform, model } = modelDef;
switch (model.format) {
case "anthropic":
return (input) => callAnthropicModel(platform, model.config, input);
case "gemini": {
const modelCaller = createCacheEnabledGeminiModelCaller(
platform,
model.config,
);
return (input) => modelCaller(model.config, input);
}
case "openai-responses":
return (input) => callOpenAIModel(platform, model.config, input);
case "openai-messages":
return (input) =>
callOpenAICompatibleModel(platform, model.config, input);
case "bedrock-converse":
return (input) => callBedrockConverseModel(platform, model.config, input);
}
}
+1
-1
{
"name": "@iinm/plain-agent",
"version": "1.15.3",
"version": "1.15.4",
"description": "A lightweight terminal-based coding agent focused on safety and low token cost",

@@ -5,0 +5,0 @@ "license": "MIT",

# 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.15.3)](https://socket.dev/npm/package/@iinm/plain-agent)
[![Socket Badge](https://badge.socket.dev/npm/package/@iinm/plain-agent/1.15.4)](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)

@@ -376,8 +376,2 @@

"apiKey": "<FIREWORKS_API_KEY>"
},
{
"name": "openai-compatible",
"variant": "novita",
"baseURL": "https://api.novita.ai/openai",
"apiKey": "<NOVITA_API_KEY>"
}

@@ -384,0 +378,0 @@ ]

import type { AgentRole } from "./context/loadAgentRoles.mjs";
import type { CostConfig, CostSummary } from "./costTracker.mjs";
import type { CostSummary } from "./metrics/costTracker.mjs";
import type {

@@ -29,3 +29,3 @@ CallModel,

send: (input: AgentInput) => void;
getCostSummary: () => CostSummary;
stop: () => void;
pauseAutoApprove: () => void;

@@ -40,3 +40,3 @@ /** Subagent currently active for this session, or null. */

*/
export type AgentEvent =
export type AgentEvent = { timestamp: Date } & (
| {

@@ -65,3 +65,4 @@ type: "session_start";

}
| { type: "messages_reset"; messages: Message[] };
| { type: "messages_reset"; messages: Message[] }
);

@@ -77,3 +78,2 @@ /** Sink used by the agent loop to push events onto the output stream. */

agentRoles: Map<string, AgentRole>;
modelCostConfig?: CostConfig;
/** Metadata used when persisting session state. */

@@ -80,0 +80,0 @@ sessionMetadata: {

/**
* @import { Agent, AgentConfig, AgentEvent, AgentInput } from "./agent"
* @import { PauseSignal } from "./agentLoop.mjs";
* @import { SystemMessage } from "./model"
* @import { Tool, ToolDefinition } from "./tool"

@@ -12,3 +14,2 @@ * @import { CompactContextInput } from "./tools/compactContext"

import { createStateManager } from "./agentState.mjs";
import { createCostTracker } from "./costTracker.mjs";
import { SESSION_FORMAT_VERSION } from "./sessionStore.mjs";

@@ -35,3 +36,2 @@ import { createSubagentManager } from "./subagent.mjs";

agentRoles,
modelCostConfig,
sessionMetadata,

@@ -42,32 +42,8 @@ initialState,

}) {
/**
* Pull-based input queue. CLI callers push input via send(); the
* agent's run loop consumes it as an async iterable.
* @type {AsyncQueue<AgentInput>}
*/
/** @type {AsyncQueue<AgentInput>} */
const inputQueue = createAsyncQueue();
/**
* Output stream of agent events, yielded by run().
* @type {AsyncQueue<AgentEvent>}
*/
/** @type {AsyncQueue<AgentEvent>} */
const eventQueue = createAsyncQueue();
const costTracker = createCostTracker(modelCostConfig);
/**
* 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);
};
// Build the initial message list. When resuming, replace messages[0] with
// the freshly built system prompt (today/agent roles/skills may have
// changed) but keep the rest of the saved conversation verbatim.
/** @type {import("./model").SystemMessage} */
/** @type {SystemMessage} */
const systemMessage = {

@@ -84,7 +60,11 @@ role: "system",

for (const message of newMessages) {
emitEvent({ type: "message", message });
eventQueue.push({ timestamp: new Date(), type: "message", message });
}
},
onMessagesReplaced: (messages) =>
emitEvent({ type: "messages_reset", messages }),
eventQueue.push({
timestamp: new Date(),
type: "messages_reset",
messages,
}),
});

@@ -94,12 +74,12 @@

onSubagentSwitched: (subagent) => {
emitEvent({ type: "subagent_switched", subagent });
eventQueue.push({
timestamp: new Date(),
type: "subagent_switched",
subagent,
});
},
});
// Restore the rest of the session state. Subagent restoration is silent
// (no event), since CLI listeners aren't attached yet — the CLI consults
// getActiveSubagent() at startup instead.
if (initialState) {
subagentManager.restoreState(initialState.subagentState);
costTracker.restoreUsageHistory(initialState.tokenUsageHistory);
}

@@ -170,3 +150,3 @@ /**

let paused = false;
/** @type {import("./agentLoop.mjs").PauseSignal} */
/** @type {PauseSignal} */
const pauseSignal = {

@@ -184,3 +164,3 @@ isPaused: () => paused,

toolExecutor,
emitEvent,
emitEvent: (event) => eventQueue.push(event),
toolUseApprover,

@@ -193,5 +173,2 @@ subagentManager,

// 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;

@@ -201,3 +178,4 @@ const emitSessionStartOnce = () => {

sessionStartEmitted = true;
emitEvent({
eventQueue.push({
timestamp: new Date(),
type: "session_start",

@@ -210,3 +188,4 @@ sessionFormatVersion: SESSION_FORMAT_VERSION,

});
emitEvent({
eventQueue.push({
timestamp: new Date(),
type: "messages_reset",

@@ -216,3 +195,5 @@ messages: stateManager.getMessages(),

};
const startInputLoop = () => {
let inputLoopStarted = false;
const startInputLoopOnce = () => {
if (inputLoopStarted) return;

@@ -225,3 +206,4 @@ inputLoopStarted = true;

})().catch((err) => {
emitEvent({
eventQueue.push({
timestamp: new Date(),
type: "error",

@@ -235,3 +217,3 @@ error: err instanceof Error ? err : new Error(String(err)),

start() {
startInputLoop();
startInputLoopOnce();
return eventQueue;

@@ -243,3 +225,6 @@ },

},
getCostSummary: () => costTracker.calculateCost(),
stop() {
inputQueue.close();
eventQueue.close();
},
pauseAutoApprove: () => {

@@ -246,0 +231,0 @@ paused = true;

/**
* @import { AgentEventSink } from "./agent"
* @import { CallModel, MessageContentText, MessageContentImage, MessageContentToolResult, PartialMessageContent, UserMessage, MessageContentToolUse } from "./model"
* @import { StateManager } from "./agentState.mjs"
* @import { CallModel, MessageContentText, MessageContentImage, MessageContentToolResult, PartialMessageContent, UserMessage, MessageContentToolUse, ProviderTokenUsage } from "./model"
* @import { ToolDefinition, ToolUseApprover } from "./tool"
* @import { ToolExecutor } from "./toolExecutor.mjs";
* @import { SubagentManager } from "./subagent.mjs"
* @import { StateManager } from "./agentState.mjs"
*/

@@ -11,36 +12,5 @@

import { buildCompactPrompt } from "./prompt.mjs";
import { extractInputTokenCount } from "./tokenUsage.mjs";
import { compactContextToolName } from "./tools/compactContext.mjs";
/**
* If compact_context was called successfully, discard the prior conversation
* (keeping only the system prompt) and append the tool result as a standard
* user message so the model can resume from the reloaded memory file.
* @param {StateManager} stateManager
* @param {MessageContentToolUse[]} toolUseParts
* @param {MessageContentToolResult[]} toolResults
* @returns {boolean} true if compact was applied
*/
function applyCompactContextIfCalled(stateManager, toolUseParts, toolResults) {
const compactToolUse = toolUseParts.find(
(t) => t.toolName === compactContextToolName,
);
if (!compactToolUse) return false;
const compactResult = toolResults.find(
(r) => r.toolUseId === compactToolUse.toolUseId,
);
if (!compactResult || compactResult.isError) return false;
const systemMessage = stateManager.getMessageAt(0);
if (!systemMessage) return false;
stateManager.replaceMessages([systemMessage]);
stateManager.appendMessages([
{ role: "user", content: compactResult.content },
]);
return true;
}
/**
* @typedef {Object} PauseSignal

@@ -56,3 +26,3 @@ * @property {() => boolean} isPaused - Returns true if auto-approve should be paused

* @property {ToolDefinition[]} toolDefs - Tool definitions for the model
* @property {import("./toolExecutor.mjs").ToolExecutor} toolExecutor - Tool executor instance
* @property {ToolExecutor} toolExecutor - Tool executor instance
* @property {AgentEventSink} emitEvent - Sink that pushes agent events onto the output stream

@@ -103,3 +73,3 @@ * @property {ToolUseApprover} toolUseApprover - Tool use approval checker

await runTurnLoop();
emitEvent({ type: "turn_end" });
emitEvent({ timestamp: new Date(), type: "turn_end" });
}

@@ -139,3 +109,7 @@

onPartialMessageContent: (partialContent) => {
emitEvent({ type: "partial_message_content", partialContent });
emitEvent({
timestamp: new Date(),
type: "partial_message_content",
partialContent,
});
},

@@ -145,3 +119,3 @@ });

if (modelOutput instanceof Error) {
emitEvent({ type: "error", error: modelOutput });
emitEvent({ timestamp: new Date(), type: "error", error: modelOutput });
break;

@@ -153,3 +127,7 @@ }

if (providerTokenUsage) {
emitEvent({ type: "token_usage", usage: providerTokenUsage });
emitEvent({
timestamp: new Date(),
type: "token_usage",
usage: providerTokenUsage,
});
}

@@ -228,2 +206,3 @@

emitEvent({
timestamp: new Date(),
type: "tool_use_request",

@@ -239,2 +218,3 @@ toolUseCount: toolUseParts.length,

emitEvent({
timestamp: new Date(),
type: "tool_use_request",

@@ -360,5 +340,5 @@ toolUseCount: toolUseParts.length,

* @property {StateManager} stateManager
* @property {import("./toolExecutor.mjs").ToolExecutor} toolExecutor
* @property {import("./subagent.mjs").SubagentManager} subagentManager
* @property {import("./tool.d.ts").ToolUseApprover} toolUseApprover
* @property {ToolExecutor} toolExecutor
* @property {SubagentManager} subagentManager
* @property {ToolUseApprover} toolUseApprover
*/

@@ -519,1 +499,56 @@

}
/**
* If compact_context was called successfully, discard the prior conversation
* (keeping only the system prompt) and append the tool result as a standard
* user message so the model can resume from the reloaded memory file.
* @param {StateManager} stateManager
* @param {MessageContentToolUse[]} toolUseParts
* @param {MessageContentToolResult[]} toolResults
* @returns {boolean} true if compact was applied
*/
function applyCompactContextIfCalled(stateManager, toolUseParts, toolResults) {
const compactToolUse = toolUseParts.find(
(t) => t.toolName === compactContextToolName,
);
if (!compactToolUse) return false;
const compactResult = toolResults.find(
(r) => r.toolUseId === compactToolUse.toolUseId,
);
if (!compactResult || compactResult.isError) return false;
const systemMessage = stateManager.getMessageAt(0);
if (!systemMessage) return false;
stateManager.replaceMessages([systemMessage]);
stateManager.appendMessages([
{ role: "user", content: compactResult.content },
]);
return true;
}
/**
* Extract the input (prompt/context) token count from a single turn's
* provider token usage object by summing the values of the specified keys.
*
* Returns `undefined` when no specified key yields a positive number.
*
* @param {ProviderTokenUsage} usage
* @param {string[]} inputTokensKeys - Keys whose numeric values are summed.
* @returns {number | undefined}
*/
export function extractInputTokenCount(usage, inputTokensKeys) {
let total = 0;
let found = false;
for (const key of inputTokensKeys) {
const value = usage[key];
if (typeof value === "number" && value > 0) {
total += value;
found = true;
}
}
return found ? total : undefined;
}

@@ -242,10 +242,11 @@ /**

plain resume [<sessionId>] [--list]
plain sandbox [-c <file>...] -- [sandbox args...]
plain cost [--from YYYY-MM-DD] [--to YYYY-MM-DD]
plain list-models
plain install-claude-code-plugins
plain sandbox [-c <file>...] -- [sandbox args...]
plain test-approval [options]
Options:
-h, --help Show this help message
-m, --model <model+variant> Model to use
-h, --help Show this help message
-c, --config <file> Config file to load (repeatable)

@@ -262,6 +263,2 @@

not supported (-m is rejected).
test-approval Run auto-approval rule tests defined in config.
sandbox Launch the sandbox command using the app
config's sandbox settings. Arguments after --
are passed through to the sandbox command as-is.
cost Show aggregated token cost per day for a period.

@@ -272,5 +269,9 @@ Defaults to the first day of the current month

install-claude-code-plugins Install Claude Code plugins
sandbox Launch the sandbox command using the app
config's sandbox settings. Arguments after --
are passed through to the sandbox command as-is.
test-approval Run auto-approval rule tests defined in config.
Examples:
plain -m claude-sonnet-4-6+thinking-high
plain -m claude-sonnet-5+thinking-high
plain batch \\

@@ -277,0 +278,0 @@ -c ~/.config/plain-agent/config.local.json \\

/**
* @import { Agent } from "../agent"
* @import { Agent, AgentEvent } from "../agent"
* @import { CostTracker } from "../metrics/costTracker.mjs";
*/
import { persistSessionEvent } from "../sessionStore.mjs";
import { appendUsageRecord, buildUsageRecord } from "../usageStore.mjs";
import { formatCostForBatch } from "./formatter.mjs";
import { appendUsageRecord, buildUsageRecord } from "../metrics/usageStore.mjs";
import {
PERSISTED_SESSION_EVENT_TYPES,
persistSessionEvent,
} from "../sessionStore.mjs";

@@ -15,4 +18,4 @@ /**

* @property {string} modelName
* @property {boolean} sandbox
* @property {Date} startTime
* @property {CostTracker} costTracker
* @property {() => Promise<void>} onStop

@@ -33,96 +36,70 @@ */

modelName,
sandbox,
startTime,
costTracker,
onStop,
}) {
outputEvent({
type: "session_start",
sessionId,
modelName,
sandbox,
timestamp: new Date().toISOString(),
});
agent.send([{ type: "text", text: task }]);
// 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;
case "error":
outputEvent({
type: "error",
error: { message: event.error.message, stack: event.error.stack },
timestamp: new Date().toISOString(),
});
process.exit(1);
break;
if (PERSISTED_SESSION_EVENT_TYPES.has(event.type)) {
outputEvent(event);
} else if (event.type === "error") {
outputEvent(event);
}
case "subagent_switched":
outputEvent({
type: "subagent_switched",
subagent: event.subagent,
timestamp: new Date().toISOString(),
});
break;
if (["error", "turn_end"].includes(event.type)) {
agent.stop();
}
}
case "token_usage":
outputEvent({
type: "token_usage",
usage: event.usage,
timestamp: new Date().toISOString(),
});
break;
const costSummary = costTracker.calculateCost();
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),
});
const record = buildUsageRecord({
sessionId,
mode: "batch",
modelName,
workingDir: process.cwd(),
costSummary,
now: startTime,
});
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(),
});
}
await onStop();
process.exit(0);
}
if (record) {
const recordError = await appendUsageRecord(record);
if (recordError instanceof Error) {
outputEvent({
type: "error",
error: recordError,
timestamp: new Date(),
});
}
}
/** @type {AgentEvent} */
const sessionEnd = {
timestamp: new Date(),
type: "session_end",
cost: costSummary,
};
await persistSessionEvent(sessionId, sessionEnd);
outputEvent(sessionEnd);
await onStop();
}
/**
* Output an event as JSON Lines format.
* Each event is a single line of JSON.
* @param {object} event
* @param {AgentEvent} event
*/
function outputEvent(event) {
console.log(JSON.stringify(event));
const { timestamp, ...rest } = event;
console.log(
JSON.stringify({
timestamp: timestamp.toISOString(),
...rest,
...(rest.type === "error"
? { error: { message: rest.error.message } }
: {}),
}),
);
}
/**
* @import { Agent } from "../agent"
* @import { CostTracker } from "../metrics/costTracker.mjs";
* @import { ClaudeCodePlugin } from "../claudeCodePlugin.mjs"

@@ -29,2 +30,3 @@ */

* @property {Agent} agent
* @property {CostTracker} costTracker
* @property {ClaudeCodePlugin[] | undefined} claudeCodePlugins

@@ -42,2 +44,3 @@ * @property {string} helpMessage

agent,
costTracker,
claudeCodePlugins,

@@ -133,3 +136,3 @@ helpMessage,

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

@@ -136,0 +139,0 @@ return "prompt";

/**
* @import { UsageRecord } from "../usageStore.mjs"
* @import { UsageRecord } from "../metrics/usageStore.mjs"
*/
import { styleText } from "node:util";
import * as usageStore from "../usageStore.mjs";
import * as usageStore from "../metrics/usageStore.mjs";

@@ -278,3 +278,3 @@ /**

* @param {{ from: string | null, to: string | null }} args
* @param {{ readUsageRecords?: typeof import("../usageStore.mjs").readUsageRecords }} [deps]
* @param {{ readUsageRecords?: typeof import("../metrics/usageStore.mjs").readUsageRecords }} [deps]
* @returns {Promise<number>} exit code

@@ -281,0 +281,0 @@ */

@@ -336,3 +336,3 @@ /**

* Format cost summary for interactive display
* @param {import("../costTracker.mjs").CostSummary} summary
* @param {import("../metrics/costTracker.mjs").CostSummary} summary
* @returns {string}

@@ -374,24 +374,2 @@ */

/**
* Format cost for batch mode JSON output
* @param {import("../costTracker.mjs").CostSummary} summary
*/
export function formatCostForBatch(summary) {
if (!summary || Object.keys(summary.breakdown).length === 0) {
return undefined;
}
return {
total: summary.totalCost,
currency: summary.currency,
unit: summary.unit,
breakdown: Object.fromEntries(
Object.entries(summary.breakdown).map(([key, { tokens, cost }]) => [
key,
{ tokens, cost },
]),
),
};
}
/**
* Print a message to the console.

@@ -398,0 +376,0 @@ * @param {Message} message

/**
* @import { Interface } from "node:readline"
* @import { Agent } from "../agent"
* @import { CostTracker } from "../metrics/costTracker.mjs";
* @import { ClaudeCodePlugin } from "../claudeCodePlugin.mjs"
* @import { Tool, SandboxModeProvider } from "../tool"
* @import { PartialMessageContent } from "../model"
*/

@@ -9,4 +12,4 @@

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

@@ -60,4 +63,5 @@ import { createCommandHandler } from "./commands.mjs";

* @property {Date} startTime
* @property {boolean} sandbox
* @property {{ command: string; args?: string[] } | undefined} notifyCmd
* @property {boolean} sandbox
* @property {CostTracker} costTracker
* @property {() => Promise<void>} onStop

@@ -69,29 +73,2 @@ * @property {ClaudeCodePlugin[]} [claudeCodePlugins]

/**
* Persist the session's cost summary to the usage log.
* Failures are logged but never thrown so exit is not blocked.
*
* @param {import("../costTracker.mjs").CostSummary} summary
* @param {{ sessionId: string, modelName: string, startTime: Date }} meta
*/
async function persistUsage(summary, { sessionId, modelName, startTime }) {
try {
const record = buildUsageRecord({
sessionId,
mode: "interactive",
modelName,
workingDir: process.cwd(),
costSummary: summary,
now: startTime,
});
if (!record) return;
await appendUsageRecord(record);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(
styleText("yellow", `Warning: failed to record usage: ${message}`),
);
}
}
/**
* @param {CliOptions} options

@@ -106,2 +83,3 @@ */

sandbox,
costTracker,
onStop,

@@ -111,49 +89,24 @@ claudeCodePlugins,

}) {
/** @type {{ turn: boolean, multiLineBuffer: string[] | null, subagentName: string, toolSpinnerIndex: number, toolSpinnerLastTime: number }} */
const state = {
turn: true,
/** @type {string[] | null} */
multiLineBuffer: null,
subagentName: agent.getActiveSubagent()?.name ?? "",
toolSpinnerIndex: 0,
toolSpinnerLastTime: 0,
};
spinnerIndex: 0,
spinnerLastTime: 0,
isExiting: false,
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const SPINNER_INTERVAL_MS = 80;
// Create the stream buffer instance for this session
const streamBuffer = createStreamBuffer();
const getCliPrompt = (subagentName = "", flashMessage = "") =>
[
"",
styleText(
["white", "bgGray"],
[
...(subagentName ? [`[${subagentName}]`] : []),
`session: ${sessionId} | model: ${modelName} | sandbox: ${sandbox ? "on" : "off"}`,
].join(" "),
),
...(flashMessage ? [flashMessage] : []),
"> ",
].join("\n");
// Cleanup handler to disable bracketed paste mode on exit
const cleanup = () => {
if (process.stdout.isTTY) {
process.stdout.write("\x1b[?2004l");
}
/** Double-press Ctrl-D exit confirmation */
lastCtrlDAttempt: new Date(0).getTime(),
};
// Handle exit signals
let isExiting = false;
const handleExit = async () => {
if (isExiting) return;
isExiting = true;
if (state.isExiting) return;
state.isExiting = true;
cleanup();
const summary = agent.getCostSummary();
const summary = costTracker.calculateCost();
const hasSessionFile = await sessionFileExists(sessionId);
if (hasSessionFile) {
await persistSessionEvent(sessionId, {
timestamp: new Date(),
type: "session_end",

@@ -163,3 +116,23 @@ cost: summary,

}
await persistUsage(summary, { sessionId, modelName, startTime });
const record = buildUsageRecord({
sessionId,
mode: "interactive",
modelName,
workingDir: process.cwd(),
costSummary: summary,
now: startTime,
});
if (record) {
const err = await appendUsageRecord(record);
if (err) {
console.error(
styleText(
"yellow",
`Warning: failed to record usage: ${err.message}`,
),
);
}
}
console.log(

@@ -176,7 +149,20 @@ [

// Double-press Ctrl-D exit confirmation
let lastCtrlDAttempt = 0;
const EXIT_CONFIRM_TIMEOUT = 1500;
process.on("SIGTERM", handleExit);
process.on("SIGHUP", handleExit);
/** @type {import("node:readline").Interface} */
const getCliPrompt = (subagentName = "", flashMessage = "") =>
[
"",
styleText(
["white", "bgGray"],
[
...(subagentName ? [`[${subagentName}]`] : []),
`session: ${sessionId} | model: ${modelName} | sandbox: ${sandbox ? "on" : "off"}`,
].join(" "),
),
...(flashMessage ? [flashMessage] : []),
"> ",
].join("\n");
/** @type {Interface} */
let cli;

@@ -225,3 +211,3 @@

// Reset Ctrl-D confirmation when Ctrl-C is pressed
lastCtrlDAttempt = 0;
state.lastCtrlDAttempt = 0;
};

@@ -236,7 +222,7 @@

const now = Date.now();
if (now - lastCtrlDAttempt < EXIT_CONFIRM_TIMEOUT) {
if (now - state.lastCtrlDAttempt < 1500) {
handleExit();
return;
}
lastCtrlDAttempt = now;
state.lastCtrlDAttempt = now;
if (state.turn) {

@@ -255,2 +241,8 @@ cli.setPrompt(

// Setup stdin
readline.emitKeypressEvents(process.stdin);
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
// Pre-readline pipeline:

@@ -263,3 +255,2 @@ // stdin -> interrupt (Ctrl-C / Ctrl-D) -> paste (bracketed paste) -> readline

const paste = createPasteHandler();
process.stdin.pipe(interrupt).pipe(paste.transform);

@@ -270,2 +261,10 @@

process.stdout.write("\x1b[?2004h");
const disableBracketedPasteMode = () => {
if (process.stdout.isTTY) {
process.stdout.write("\x1b[?2004l");
}
};
process.on("exit", disableBracketedPasteMode);
process.on("SIGTERM", disableBracketedPasteMode);
process.on("SIGHUP", disableBracketedPasteMode);
}

@@ -280,2 +279,3 @@

});
cli.on("close", handleExit);

@@ -294,14 +294,5 @@ // Disable automatic prompt redraw on resize during agent turn

readline.emitKeypressEvents(process.stdin);
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
// Handle readline close (e.g., stdin closed externally)
cli.on("close", handleExit);
process.on("SIGTERM", handleExit);
process.on("SIGHUP", handleExit);
const handleCommand = createCommandHandler({
agent,
costTracker,
claudeCodePlugins,

@@ -377,5 +368,9 @@ helpMessage: HELP_MESSAGE,

const outputStreamBuffer = createStreamBuffer();
const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const spinnerIntervalMs = 80;
/**
* Render a streaming partial message content chunk.
* @param {import("../model").PartialMessageContent} partialContent
* @param {PartialMessageContent} partialContent
*/

@@ -390,6 +385,6 @@ const handlePartialMessageContent = (partialContent) => {

if (["thinking", "tool_use"].includes(partialContent.type)) {
state.toolSpinnerIndex = 0;
state.toolSpinnerLastTime = Date.now();
state.spinnerIndex = 0;
state.spinnerLastTime = Date.now();
process.stdout.write(
`\n${subagentPrefix}${partialContentStr} ${styleText("cyan", SPINNER_FRAMES[0])}`,
`\n${subagentPrefix}${partialContentStr} ${styleText("cyan", spinnerFrames[0])}`,
);

@@ -403,12 +398,11 @@ } else {

const now = Date.now();
if (now - state.toolSpinnerLastTime >= SPINNER_INTERVAL_MS) {
state.toolSpinnerIndex =
(state.toolSpinnerIndex + 1) % SPINNER_FRAMES.length;
state.toolSpinnerLastTime = now;
if (now - state.spinnerLastTime >= spinnerIntervalMs) {
state.spinnerIndex = (state.spinnerIndex + 1) % spinnerFrames.length;
state.spinnerLastTime = now;
process.stdout.write(
`\r\x1b[K${styleText("gray", `<${partialContent.type}>`)} ${styleText("cyan", SPINNER_FRAMES[state.toolSpinnerIndex])}`,
`\r\x1b[K${styleText("gray", `<${partialContent.type}>`)} ${styleText("cyan", spinnerFrames[state.spinnerIndex])}`,
);
}
} else if (partialContent.type === "text") {
streamBuffer.feed(partialContent.content);
outputStreamBuffer.feed(partialContent.content);
} else {

@@ -423,4 +417,3 @@ process.stdout.write(partialContent.content);

} else {
// Flush any buffered text before printing the closing tag
streamBuffer.forceFlush();
outputStreamBuffer.forceFlush();
console.log(styleText("gray", `\n</${partialContent.type}>`));

@@ -431,5 +424,2 @@ }

// 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 () => {

@@ -489,4 +479,3 @@ for await (const event of agent.start()) {

case "turn_end": {
// Flush any remaining stream buffer content
streamBuffer.forceFlush();
outputStreamBuffer.forceFlush();

@@ -500,5 +489,2 @@ const err = notify(notifyCmd);

// Defer prompt rendering to ensure terminal output is visible
await new Promise((resolve) => setImmediate(resolve));
state.turn = true;

@@ -511,9 +497,5 @@ cli.prompt();

};
consumeAgentEvents();
cli.prompt();
// Register cleanup handlers
process.on("exit", cleanup);
process.on("SIGTERM", cleanup);
}

@@ -520,0 +502,0 @@

import { ClaudeCodePluginRepo } from "./claudeCodePlugin.mjs";
import { ModelDefinition, PlatformConfig } from "./modelDefinition";
import { ModelDefinition, PlatformConfig } from "./model.definition";
import { ToolUsePattern } from "./tool";

@@ -4,0 +4,0 @@ import { ExecCommandSanboxConfig } from "./tools/execCommand";

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

import { setupMCPServer } from "./mcp/integration.mjs";
import { createModelCaller } from "./modelCaller.mjs";
import { createCostTracker } from "./metrics/costTracker.mjs";
import { createModelCaller } from "./model.mjs";
import { createPrompt } from "./prompt.mjs";

@@ -441,3 +442,2 @@ import { listSessions, loadSession } from "./sessionStore.mjs";

// Resolve context soft limit from autoCompact config
const contextSoftLimit = resolveContextSoftLimit(

@@ -458,4 +458,15 @@ appConfig.autoCompact,

const costTracker = createCostTracker(modelDef.cost);
if (resumedState?.tokenUsageHistory) {
costTracker.restoreUsageHistory(resumedState.tokenUsageHistory);
}
const agent = createAgent({
callModel: agentCallModel,
callModel: async (input) => {
const output = await agentCallModel(input);
if (!(output instanceof Error) && output.providerTokenUsage) {
costTracker.recordUsage(output.providerTokenUsage);
}
return output;
},
prompt,

@@ -465,3 +476,2 @@ tools: [...builtinTools, ...mcpTools],

agentRoles,
modelCostConfig: modelDef.cost,
sessionMetadata: {

@@ -484,2 +494,3 @@ sessionId,

startTime,
costTracker,
onStop: async () => {

@@ -501,10 +512,11 @@ for (const cleanup of mcpCleanups) {

});
} else {
startInteractiveSession({
...sessionOptions,
execCommandTool,
notifyCmd: appConfig.notifyCmd,
claudeCodePlugins: resolvePluginPaths(appConfig.claudeCodePlugins ?? []),
});
return;
}
startInteractiveSession({
...sessionOptions,
execCommandTool,
notifyCmd: appConfig.notifyCmd,
claudeCodePlugins: resolvePluginPaths(appConfig.claudeCodePlugins ?? []),
});
}

@@ -511,0 +523,0 @@

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

/**
* @param {import("../modelDefinition").PlatformConfig} platformConfig
* @param {import("../model.definition").PlatformConfig} platformConfig
* @param {AnthropicModelConfig} modelConfig

@@ -17,0 +17,0 @@ * @param {ModelInput} input

@@ -13,3 +13,3 @@ /**

/**
* @param {import("../modelDefinition").PlatformConfig} platformConfig
* @param {import("../model.definition").PlatformConfig} platformConfig
* @param {BedrockConverseModelConfig} modelConfig

@@ -16,0 +16,0 @@ * @param {ModelInput} input

@@ -23,3 +23,3 @@ /**

* - https://ai.google.dev/api/caching
* @param {import("../modelDefinition").PlatformConfig} platformConfig
* @param {import("../model.definition").PlatformConfig} platformConfig
* @param {GeminiModelConfig} modelConfig

@@ -26,0 +26,0 @@ * @returns {GeminiModelCaller}

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

/**
* @param {import("../modelDefinition").PlatformConfig} platformConfig
* @param {import("../model.definition").PlatformConfig} platformConfig
* @param {OpenAIModelConfig} modelConfig

@@ -15,0 +15,0 @@ * @param {ModelInput} input

@@ -16,3 +16,3 @@ /**

/**
* @param {import("../modelDefinition").PlatformConfig} platformConfig
* @param {import("../model.definition").PlatformConfig} platformConfig
* @param {OpenAICompatibleModelConfig} modelConfig

@@ -19,0 +19,0 @@ * @param {ModelInput} input

@@ -13,3 +13,3 @@ /**

/** Event types that are persisted in session JSONL streams. */
const WRITABLE_EVENT_TYPES = new Set([
export const PERSISTED_SESSION_EVENT_TYPES = new Set([
"session_start",

@@ -84,13 +84,15 @@ "message",

* @param {string} sessionId
* @param {{ type: string, [key: string]: unknown }} event
* @param {import("./agent").AgentEvent} event
* @param {{ dir?: string }} [options]
*/
export async function persistSessionEvent(sessionId, event, options = {}) {
if (!WRITABLE_EVENT_TYPES.has(event.type)) return;
if (!PERSISTED_SESSION_EVENT_TYPES.has(event.type)) return;
const dir = options.dir ?? SESSIONS_DIR;
await fs.mkdir(dir, { recursive: true });
const { timestamp, ...rest } = event;
const line = JSON.stringify({
...event,
timestamp: new Date().toISOString(),
timestamp: event.timestamp.toISOString(),
...rest,
});

@@ -97,0 +99,0 @@ await fs.appendFile(sessionFilePath(sessionId, { dir }), `${line}\n`, "utf8");

/**
* @import { ProviderTokenUsage } from "./model"
*/
/**
* @typedef {Object} TokenBreakdown
* @property {number} tokens - Token count
* @property {number | undefined} cost - Cost (undefined if no pricing)
*/
/**
* @typedef {Object} CostSummary
* @property {string} currency - Currency code (e.g., "USD")
* @property {string} unit - Unit size (e.g., "1M")
* @property {Record<string, TokenBreakdown>} breakdown - Token breakdown
* @property {number | undefined} totalCost - Total cost (undefined if no pricing)
*/
/**
* @typedef {Object} CostConfig
* @property {string} currency
* @property {string} unit
* @property {Record<string, number>} prices
*/
/**
* @typedef {Object} CostTracker
* @property {(usage: ProviderTokenUsage) => void} recordUsage - Record token usage
* @property {() => Record<string, number>} getAggregatedUsage - Get aggregated usage
* @property {() => CostSummary} calculateCost - Calculate cost summary
* @property {() => boolean} hasUsage - Check if any usage recorded
* @property {() => ProviderTokenUsage[]} getUsageHistory - Get a snapshot of the raw usage history
* @property {(history: ProviderTokenUsage[]) => void} restoreUsageHistory - Replace the usage history (used when resuming a saved session)
*/
/**
* Validate a cost configuration object at runtime.
* @param {unknown} config
*/
function validateCostConfig(config) {
if (config === undefined) return;
if (typeof config !== "object" || config === null) {
throw new TypeError("CostConfig must be an object");
}
const c = /** @type {Record<string, unknown>} */ (config);
if (typeof c.currency !== "string") {
throw new TypeError("CostConfig.currency must be a string");
}
if (typeof c.unit !== "string") {
throw new TypeError("CostConfig.unit must be a string");
}
if (typeof c.prices !== "object" || c.prices === null) {
throw new TypeError("CostConfig.prices must be an object");
}
for (const [key, value] of Object.entries(
/** @type {Record<string, unknown>} */ (c.prices),
)) {
if (typeof value !== "number") {
throw new TypeError(
`CostConfig.prices["${key}"] must be a number, got ${typeof value}`,
);
}
}
}
/**
* Create a cost tracker for session token usage
* @param {CostConfig} [costConfig] - Optional cost configuration
* @returns {CostTracker}
*/
export function createCostTracker(costConfig) {
validateCostConfig(costConfig);
/** @type {ProviderTokenUsage[]} */
const usageHistory = [];
/**
* Record token usage from a provider.
* Throws when usage is not a non-null object.
* @param {ProviderTokenUsage} usage
* @throws {TypeError} when usage is null, undefined, or not an object
*/
function recordUsage(usage) {
if (typeof usage !== "object" || usage === null) {
throw new TypeError("usage must be a non-null object");
}
usageHistory.push(usage);
}
/**
* Get aggregated token usage
* @returns {Record<string, number>}
*/
function getAggregatedUsage() {
return aggregateTokens(usageHistory);
}
/**
* Calculate cost summary
* @returns {CostSummary}
*/
function calculateCost() {
const aggregated = aggregateTokens(usageHistory);
return calculateCostFromConfig(aggregated, costConfig);
}
/**
* Check if any usage recorded
* @returns {boolean}
*/
function hasUsage() {
return usageHistory.length > 0;
}
/**
* Get a snapshot copy of the raw usage history.
* @returns {ProviderTokenUsage[]}
*/
function getUsageHistory() {
return usageHistory.map((u) => u);
}
/**
* Replace the usage history. Used when resuming a saved session.
* @param {ProviderTokenUsage[]} history
*/
function restoreUsageHistory(history) {
if (!Array.isArray(history)) {
throw new TypeError("history must be an array");
}
usageHistory.length = 0;
for (const usage of history) {
if (typeof usage !== "object" || usage === null) {
throw new TypeError("each usage entry must be a non-null object");
}
usageHistory.push(usage);
}
}
return Object.freeze({
recordUsage,
getAggregatedUsage,
calculateCost,
hasUsage,
getUsageHistory,
restoreUsageHistory,
});
}
/**
* Aggregate token usage history by key
* @param {ProviderTokenUsage[]} usageHistory
* @returns {Record<string, number>}
*/
function aggregateTokens(usageHistory) {
/** @type {Record<string, number>} */
const aggregated = {};
for (const usage of usageHistory) {
recursivelySumValues(usage, [], aggregated);
}
return aggregated;
}
/**
* Recursively sum numeric values in token usage
* @param {ProviderTokenUsage} obj
* @param {string[]} path
* @param {Record<string, number>} result
*/
function recursivelySumValues(obj, path, result) {
for (const [key, value] of Object.entries(obj)) {
const currentPath = [...path, key];
const pathStr = currentPath.join(".");
if (typeof value === "number") {
result[pathStr] = (result[pathStr] || 0) + value;
} else if (
typeof value === "object" &&
value !== null &&
!Array.isArray(value)
) {
recursivelySumValues(value, currentPath, result);
}
}
}
/**
* Calculate cost from aggregated tokens and config
* @param {Record<string, number>} aggregated
* @param {CostConfig | undefined} config
* @returns {CostSummary}
*/
function calculateCostFromConfig(aggregated, config) {
/** @type {Record<string, TokenBreakdown>} */
const breakdown = {};
let totalCost = 0;
for (const [key, tokens] of Object.entries(aggregated)) {
breakdown[key] = Object.freeze({ tokens, cost: undefined });
if (!config?.prices?.[key]) {
continue;
}
const costValue = config.prices[key];
const unitSize = parseUnit(config.unit);
if (typeof costValue !== "number") {
throw new TypeError(
`config.prices["${key}"] must be a number, got ${typeof costValue}`,
);
}
const cost = (tokens * costValue) / unitSize;
breakdown[key] = Object.freeze({ tokens, cost });
totalCost += cost;
}
return Object.freeze({
currency: config?.currency ?? "USD",
unit: config?.unit ?? "1M",
breakdown,
totalCost: config?.prices ? totalCost : undefined,
});
}
/**
* Parse unit string to number.
* @param {string} unit
* @returns {number}
* @throws {Error} when the unit is not recognized
*/
function parseUnit(unit) {
if (unit === "1M") return 1_000_000;
if (unit === "1K") return 1_000;
throw new Error(`Unknown cost unit: "${unit}"`);
}
import { callAnthropicModel } from "./providers/anthropic.mjs";
import { callBedrockConverseModel } from "./providers/bedrock.mjs";
import { createCacheEnabledGeminiModelCaller } from "./providers/gemini.mjs";
import { callOpenAIModel } from "./providers/openai.mjs";
import { callOpenAICompatibleModel } from "./providers/openaiCompatible.mjs";
/**
* @param {import("./modelDefinition").ModelDefinition} modelDef
* @returns {import("./model").CallModel}
*/
export function createModelCaller(modelDef) {
const { platform, model } = modelDef;
switch (model.format) {
case "anthropic":
return (input) => callAnthropicModel(platform, model.config, input);
case "gemini": {
const modelCaller = createCacheEnabledGeminiModelCaller(
platform,
model.config,
);
return (input) => modelCaller(model.config, input);
}
case "openai-responses":
return (input) => callOpenAIModel(platform, model.config, input);
case "openai-messages":
return (input) =>
callOpenAICompatibleModel(platform, model.config, input);
case "bedrock-converse":
return (input) => callBedrockConverseModel(platform, model.config, input);
}
}
import { AnthropicModelConfig } from "./providers/anthropic";
import { BedrockConverseModelConfig } from "./providers/bedrock";
import { GeminiModelConfig } from "./providers/gemini";
import { OpenAIModelConfig } from "./providers/openai";
import { OpenAICompatibleModelConfig } from "./providers/openaiCompatible";
export type ModelDefinition = {
name: string;
variant: string;
platform: PlatformConfig;
model: ModelConfig;
cost?: CostConfig;
autoCompact?: ModelAutoCompactConfig;
};
export type ModelAutoCompactConfig = {
/** Keys in providerTokenUsage whose values are summed to get the input token count. */
inputTokensKeys: string[];
};
export type PlatformConfig =
| {
name: "anthropic";
variant: string;
baseURL: string;
customHeaders?: Record<string, string>;
apiKey: string;
}
| {
name: "gemini";
variant: string;
baseURL: string;
customHeaders?: Record<string, string>;
apiKey: string;
}
| {
name: "openai";
variant: string;
baseURL: string;
customHeaders?: Record<string, string>;
apiKey: string;
}
| {
name: "openai-compatible";
variant: string;
baseURL: string;
customHeaders?: Record<string, string>;
apiKey: string;
}
| {
name: "azure";
variant: string;
baseURL: string;
customHeaders?: Record<string, string>;
azureConfigDir?: string;
}
| {
name: "bedrock";
variant: string;
baseURL: string;
customHeaders?: Record<string, string>;
awsProfile: string;
}
| {
name: "vertex-ai";
variant: string;
baseURL: string;
customHeaders?: Record<string, string>;
account?: string;
};
export type ModelConfig =
| {
format: "anthropic";
config: AnthropicModelConfig;
}
| {
format: "gemini";
config: GeminiModelConfig;
}
| {
format: "openai-responses";
config: OpenAIModelConfig;
}
| {
format: "openai-messages";
config: OpenAICompatibleModelConfig;
}
| {
format: "bedrock-converse";
config: BedrockConverseModelConfig;
};
export type CostConfig = {
currency: string;
unit: string;
prices: Record<string, number>;
};
/**
* @import { ProviderTokenUsage } from "./model"
*/
/**
* Extract the input (prompt/context) token count from a single turn's
* provider token usage object by summing the values of the specified keys.
*
* Returns `undefined` when no specified key yields a positive number.
*
* @param {ProviderTokenUsage} usage
* @param {string[]} inputTokensKeys - Keys whose numeric values are summed.
* @returns {number | undefined}
*/
export function extractInputTokenCount(usage, inputTokensKeys) {
let total = 0;
let found = false;
for (const key of inputTokensKeys) {
const value = usage[key];
if (typeof value === "number" && value > 0) {
total += value;
found = true;
}
}
return found ? total : undefined;
}
/**
* @import { CostSummary } from "./costTracker.mjs"
*/
import fs from "node:fs/promises";
import path from "node:path";
import { USAGE_LOG_PATH } from "./env.mjs";
/**
* @typedef {Object} UsageRecord
* @property {string} timestamp - ISO 8601 timestamp
* @property {string} sessionId
* @property {"interactive" | "batch"} mode
* @property {string} modelName - e.g. "claude-sonnet-4-6+thinking-high"
* @property {string} workingDir
* @property {string} currency - e.g. "USD"
* @property {string} unit - e.g. "1M"
* @property {number | null} totalCost - null when no pricing configured
* @property {Record<string, number>} tokens - aggregated token counts by path
*/
/**
* Maximum size (in bytes) of a single JSONL line.
* Linux guarantees atomicity of O_APPEND writes up to PIPE_BUF (4096 bytes),
* so we enforce a smaller limit to stay safely under that threshold even
* with multi-byte UTF-8 characters in model/session names.
*/
const MAX_RECORD_BYTES = 3072;
/**
* Append a usage record to the persistent usage log.
*
* On POSIX systems, `fs.appendFile` opens the file with `O_APPEND`, which
* guarantees that each write lands at end-of-file and is atomic when the
* payload is <= PIPE_BUF (4096 bytes on Linux). We write the record as a
* single call to preserve this guarantee even if multiple plain-agent
* sessions finish simultaneously.
*
* @param {UsageRecord} record
* @param {{ path?: string }} [options]
* @returns {Promise<void>}
*/
export async function appendUsageRecord(record, options = {}) {
const filePath = options.path ?? USAGE_LOG_PATH;
const line = `${JSON.stringify(record)}\n`;
const bytes = Buffer.byteLength(line, "utf8");
if (bytes > MAX_RECORD_BYTES) {
throw new Error(
`Usage record exceeds ${MAX_RECORD_BYTES} bytes (${bytes}); refusing to write to keep appends atomic.`,
);
}
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.appendFile(filePath, line, { encoding: "utf8" });
}
/**
* Read all usage records from the log file.
* Malformed lines are skipped and collected in `skipped`.
*
* @param {{ path?: string }} [options]
* @returns {Promise<{ records: UsageRecord[], skipped: { line: number, reason: string }[] }>}
*/
export async function readUsageRecords(options = {}) {
const filePath = options.path ?? USAGE_LOG_PATH;
/** @type {string} */
let content;
try {
content = await fs.readFile(filePath, "utf8");
} catch (err) {
if (
err instanceof Error &&
/** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT"
) {
return { records: [], skipped: [] };
}
throw err;
}
/** @type {UsageRecord[]} */
const records = [];
/** @type {{ line: number, reason: string }[]} */
const skipped = [];
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const raw = lines[i];
if (raw.length === 0) continue;
try {
const parsed = JSON.parse(raw);
if (!isUsageRecord(parsed)) {
skipped.push({ line: i + 1, reason: "invalid shape" });
continue;
}
records.push(parsed);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
skipped.push({ line: i + 1, reason });
}
}
return { records, skipped };
}
/**
* Build a usage record from a finished session's cost summary.
* Returns null when there's nothing worth recording (no tokens).
*
* @param {Object} args
* @param {string} args.sessionId
* @param {"interactive" | "batch"} args.mode
* @param {string} args.modelName
* @param {string} args.workingDir
* @param {CostSummary} args.costSummary
* @param {Date} [args.now]
* @returns {UsageRecord | null}
*/
export function buildUsageRecord({
sessionId,
mode,
modelName,
workingDir,
costSummary,
now,
}) {
/** @type {Record<string, number>} */
const tokens = {};
for (const [key, entry] of Object.entries(costSummary.breakdown)) {
tokens[key] = entry.tokens;
}
if (Object.keys(tokens).length === 0) {
return null;
}
const timestamp = (now ?? new Date()).toISOString();
return {
timestamp,
sessionId,
mode,
modelName,
workingDir,
currency: costSummary.currency,
unit: costSummary.unit,
totalCost:
costSummary.totalCost === undefined ? null : costSummary.totalCost,
tokens,
};
}
/**
* @param {unknown} value
* @returns {value is UsageRecord}
*/
function isUsageRecord(value) {
if (typeof value !== "object" || value === null) return false;
const r = /** @type {Record<string, unknown>} */ (value);
return (
typeof r.timestamp === "string" &&
typeof r.sessionId === "string" &&
(r.mode === "interactive" || r.mode === "batch") &&
typeof r.modelName === "string" &&
typeof r.workingDir === "string" &&
typeof r.currency === "string" &&
typeof r.unit === "string" &&
(r.totalCost === null || typeof r.totalCost === "number") &&
typeof r.tokens === "object" &&
r.tokens !== null
);
}

Sorry, the diff of this file is too big to display