From d11fcdbe7543c24c2087ba446293cde7ec829bc1 Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sun, 19 Apr 2026 15:47:46 +0800 Subject: [PATCH 01/21] feat: add durable token usage tracking --- container/agent-runner/src/index.ts | 88 ++++++++++ src/agent/agent-impl.ts | 2 + src/agent/message-processor.ts | 36 ++-- src/agent/usage-actions.test.ts | 220 +++++++++++++++++++++++++ src/agent/usage-actions.ts | 29 ++++ src/container-runner.ts | 17 ++ src/db.test.ts | 35 ++++ src/db.ts | 246 ++++++++++++++++++++++++++++ src/token-pricing.test.ts | 51 ++++++ src/token-pricing.ts | 52 ++++++ src/token-usage-runtime.test.ts | 156 ++++++++++++++++++ 11 files changed, 919 insertions(+), 13 deletions(-) create mode 100644 src/agent/usage-actions.test.ts create mode 100644 src/agent/usage-actions.ts create mode 100644 src/token-pricing.test.ts create mode 100644 src/token-pricing.ts create mode 100644 src/token-usage-runtime.test.ts diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 11908d2eedc..4020917704d 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -20,6 +20,7 @@ import { query, HookCallback, PreCompactHookInput, + type SDKResultMessage, } from '@anthropic-ai/claude-agent-sdk'; import { fileURLToPath } from 'url'; @@ -77,10 +78,27 @@ interface ContainerSdkMessageOutput { message: unknown; } +interface ContainerTokenUsageOutput { + type: 'token_usage'; + usage: { + group_jid: string; + session_id: string | null; + model: string; + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + cache_read_tokens: number; + cache_write_tokens: number; + latency_ms: number; + ts: number; + }; +} + type ContainerOutput = | ContainerStateOutput | ContainerResultOutput | ContainerErrorOutput + | ContainerTokenUsageOutput | ContainerSdkMessageOutput; interface SessionEntry { @@ -168,6 +186,62 @@ function log(message: string): void { console.error(`[agent-runner] ${message}`); } +function resolveUsageModel( + currentModel: string | undefined, + modelUsage: SDKResultMessage['modelUsage'], +): string { + const models = Object.entries(modelUsage); + if (models.length === 1) { + return models[0]![0]; + } + if (currentModel && models.some(([model]) => model === currentModel)) { + return currentModel; + } + if (currentModel) { + return currentModel; + } + if (models.length === 0) { + return 'unknown'; + } + + return models + .slice() + .sort((a, b) => { + const totalA = a[1].inputTokens + a[1].outputTokens; + const totalB = b[1].inputTokens + b[1].outputTokens; + if (totalA !== totalB) return totalB - totalA; + return a[0].localeCompare(b[0]); + })[0]![0]; +} + +function captureTokenUsageSummary(params: { + groupJid: string; + sessionId: string | undefined; + currentModel: string | undefined; + queryStartedAt: number; + message: SDKResultMessage; +}): ContainerTokenUsageOutput['usage'] { + const usage = params.message.usage; + const promptTokens = usage.input_tokens ?? 0; + const completionTokens = usage.output_tokens ?? 0; + const cacheReadTokens = usage.cache_read_input_tokens ?? 0; + const cacheWriteTokens = usage.cache_creation_input_tokens ?? 0; + const ts = Date.now(); + + return { + group_jid: params.groupJid, + session_id: params.sessionId ?? null, + model: resolveUsageModel(params.currentModel, params.message.modelUsage), + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + cache_read_tokens: cacheReadTokens, + cache_write_tokens: cacheWriteTokens, + latency_ms: ts - params.queryStartedAt, + ts, + }; +} + function getSessionSummary( sessionId: string, transcriptPath: string, @@ -444,6 +518,7 @@ async function runQuery( lastAssistantUuid?: string; closedDuringQuery: boolean; }> { + const queryStartedAt = Date.now(); const stream = new MessageStream(); stream.push(prompt); writeOutput({ @@ -476,6 +551,7 @@ async function runQuery( let newSessionId: string | undefined; let lastAssistantUuid: string | undefined; + let currentModel: string | undefined; let messageCount = 0; let resultCount = 0; @@ -601,6 +677,7 @@ async function runQuery( } if (message.type === 'system' && message.subtype === 'init') { newSessionId = message.session_id; + currentModel = message.model; log(`Session initialized: ${newSessionId}`); } @@ -619,6 +696,17 @@ async function runQuery( // ── Backward-compat: emit result for host message delivery ─ if (message.type === 'result') { resultCount++; + writeOutput({ + type: 'token_usage', + usage: captureTokenUsageSummary({ + groupJid: containerInput.chatJid, + sessionId: newSessionId ?? sessionId, + currentModel, + queryStartedAt, + message, + }), + }); + const textResult = 'result' in message ? (message as { result?: string }).result : null; log( diff --git a/src/agent/agent-impl.ts b/src/agent/agent-impl.ts index 00c40ae67f2..32e6edd11c6 100644 --- a/src/agent/agent-impl.ts +++ b/src/agent/agent-impl.ts @@ -61,6 +61,7 @@ import { ChannelManager } from './channel-manager.js'; import { GroupManager } from './group-manager.js'; import { TaskManager } from './task-manager.js'; import { MessageProcessor } from './message-processor.js'; +import { registerUsageActions } from './usage-actions.js'; export { type Agent }; @@ -442,6 +443,7 @@ export class AgentImpl dataDir: this.config.dataDir, assistantName: this.config.assistantName, }); + registerUsageActions(this, this.db); logger.info({ agent: this.name }, 'Database initialized'); this.groupMgr.loadState(); diff --git a/src/agent/message-processor.ts b/src/agent/message-processor.ts index bf9ceb8ca04..c6cdbe46b3b 100644 --- a/src/agent/message-processor.ts +++ b/src/agent/message-processor.ts @@ -348,20 +348,30 @@ export class MessageProcessor { this.ctx.config.dataDir, ); - const wrappedOnOutput = onOutput - ? async (output: ContainerEvent) => { - if ( - (output.type === 'state' || - output.type === 'result' || - output.type === 'error') && - output.newSessionId - ) { - this.ctx.sessions[group.folder] = output.newSessionId; - this.ctx.db.setSession(group.folder, output.newSessionId); - } - await onOutput(output); + const wrappedOnOutput = async (output: ContainerEvent) => { + if ( + (output.type === 'state' || + output.type === 'result' || + output.type === 'error') && + output.newSessionId + ) { + this.ctx.sessions[group.folder] = output.newSessionId; + this.ctx.db.setSession(group.folder, output.newSessionId); + } + + if (output.type === 'token_usage') { + try { + this.ctx.db.recordTokenUsage(output.usage); + } catch (err) { + logger.warn( + { group: group.name, err }, + 'Failed to record token usage', + ); } - : undefined; + } + + await onOutput?.(output); + }; try { const actionAuth = this.ctx.actionsHttp.mintContainerToken( diff --git a/src/agent/usage-actions.test.ts b/src/agent/usage-actions.test.ts new file mode 100644 index 00000000000..ccd1633cd89 --- /dev/null +++ b/src/agent/usage-actions.test.ts @@ -0,0 +1,220 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { createAgentLite } from '../api/sdk.js'; +import type { Agent } from '../api/agent.js'; +import type { AgentLite } from '../api/sdk.js'; +import type { AgentDb } from '../db.js'; +import type { ActionsHttp } from './actions-http.js'; + +type AgentWithHttpAndDb = Agent & { + actionsHttp: ActionsHttp; + db: AgentDb; +}; + +describe('usage_get_summary action', () => { + let tmpDir: string; + let platform: AgentLite; + let agent: AgentWithHttpAndDb; + let token: string | undefined; + let url: string | undefined; + + beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentlite-usage-action-')); + platform = await createAgentLite({ workdir: tmpDir }); + agent = platform.createAgent('usage-test') as AgentWithHttpAndDb; + await agent.start(); + + const info = agent.actionsHttp.getInfo(); + if (!info) { + throw new Error('No LAN IP available — can not run usage action tests'); + } + url = info.url; + token = agent.actionsHttp.mintContainerToken('test-group', false)?.token; + }); + + afterEach(async () => { + await agent?.stop(); + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + }); + + function seedUsageRows(): void { + agent.db.recordTokenUsage({ + group_jid: 'group-1@g.us', + session_id: 'session-a', + model: 'claude-sonnet-4-6', + prompt_tokens: 100, + completion_tokens: 50, + cache_read_tokens: 10, + cache_write_tokens: 5, + latency_ms: 100, + ts: 1_000, + }); + agent.db.recordTokenUsage({ + group_jid: 'group-1@g.us', + session_id: 'session-a', + model: 'claude-sonnet-4-6', + prompt_tokens: 200, + completion_tokens: 100, + latency_ms: 125, + ts: 2_000, + }); + agent.db.recordTokenUsage({ + group_jid: 'group-2@g.us', + session_id: 'session-b', + model: 'claude-opus-4-6', + prompt_tokens: 300, + completion_tokens: 150, + latency_ms: 150, + ts: 3_000, + }); + } + + async function callSummary(payload?: Record) { + const res = await fetch(`${url}/call`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: 'usage_get_summary', + payload, + }), + }); + const json = (await res.json()) as { + result: { + total_tokens: number; + prompt_tokens: number; + completion_tokens: number; + cache_read_tokens: number; + cache_write_tokens: number; + total_cost_usd: number | null; + request_count: number; + by_model: Array<{ + model: string; + request_count: number; + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + cost_usd: number | null; + }>; + by_session: Array<{ + session_id: string | null; + request_count: number; + total_tokens: number; + cost_usd: number | null; + }>; + }; + }; + return { status: res.status, result: json.result }; + } + + it('returns all rows when no filters are provided', async () => { + seedUsageRows(); + + const response = await callSummary(); + expect(response.status).toBe(200); + expect(response.result).toMatchObject({ + total_tokens: 900, + prompt_tokens: 600, + completion_tokens: 300, + cache_read_tokens: 10, + cache_write_tokens: 5, + request_count: 3, + }); + expect(response.result.total_cost_usd).toBeCloseTo(0.01892175); + + const bySessionA = response.result.by_session.find( + (row) => row.session_id === 'session-a', + ); + const bySessionB = response.result.by_session.find( + (row) => row.session_id === 'session-b', + ); + expect(bySessionA).toMatchObject({ + session_id: 'session-a', + request_count: 2, + total_tokens: 450, + }); + expect(bySessionA?.cost_usd).toBeCloseTo(0.00317175); + expect(bySessionB).toMatchObject({ + session_id: 'session-b', + request_count: 1, + total_tokens: 450, + }); + expect(bySessionB?.cost_usd).toBeCloseTo(0.01575); + }); + + it('filters rows by group_jid', async () => { + seedUsageRows(); + + const response = await callSummary({ group_jid: 'group-1@g.us' }); + expect(response.status).toBe(200); + expect(response.result).toMatchObject({ + total_tokens: 450, + prompt_tokens: 300, + completion_tokens: 150, + request_count: 2, + }); + expect(response.result.total_cost_usd).toBeCloseTo(0.00317175); + expect(response.result.by_model).toHaveLength(1); + expect(response.result.by_model[0]).toMatchObject({ + model: 'claude-sonnet-4-6', + request_count: 2, + total_tokens: 450, + }); + }); + + it('filters rows by since timestamp', async () => { + seedUsageRows(); + + const response = await callSummary({ since: 1_500 }); + expect(response.status).toBe(200); + expect(response.result).toMatchObject({ + total_tokens: 750, + prompt_tokens: 500, + completion_tokens: 250, + request_count: 2, + }); + expect(response.result.total_cost_usd).toBeCloseTo(0.01785); + }); + + it('returns the correct by_model breakdown', async () => { + seedUsageRows(); + + const response = await callSummary(); + expect(response.status).toBe(200); + + const sonnet = response.result.by_model.find( + (row) => row.model === 'claude-sonnet-4-6', + ); + const opus = response.result.by_model.find( + (row) => row.model === 'claude-opus-4-6', + ); + + expect(sonnet).toMatchObject({ + model: 'claude-sonnet-4-6', + request_count: 2, + prompt_tokens: 300, + completion_tokens: 150, + total_tokens: 450, + }); + expect(sonnet?.cost_usd).toBeCloseTo(0.00317175); + + expect(opus).toMatchObject({ + model: 'claude-opus-4-6', + request_count: 1, + prompt_tokens: 300, + completion_tokens: 150, + total_tokens: 450, + }); + expect(opus?.cost_usd).toBeCloseTo(0.01575); + }); +}); diff --git a/src/agent/usage-actions.ts b/src/agent/usage-actions.ts new file mode 100644 index 00000000000..9fc981fc922 --- /dev/null +++ b/src/agent/usage-actions.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +import type { Agent } from '../api/agent.js'; +import type { AgentDb } from '../db.js'; + +export function registerUsageActions(agent: Agent, db: AgentDb): void { + agent.action( + 'usage_get_summary', + 'Return aggregated token usage, cost, and per-model/session breakdowns from the local runtime store.', + { + group_jid: z + .string() + .optional() + .describe('Filter to a specific group or channel JID'), + model: z + .string() + .optional() + .describe('Filter to a specific model identifier'), + since: z + .number() + .int() + .optional() + .describe( + 'Only include rows with a timestamp strictly greater than this Unix timestamp in milliseconds', + ), + }, + async (args) => db.getTokenUsageSummary(args), + ); +} diff --git a/src/container-runner.ts b/src/container-runner.ts index e381005a6d7..64c5b9e7e51 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -96,6 +96,22 @@ export interface ContainerErrorEvent { newSessionId?: string; } +export interface ContainerTokenUsageEvent { + type: 'token_usage'; + usage: { + group_jid: string; + session_id: string | null; + model: string; + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + cache_read_tokens: number; + cache_write_tokens: number; + latency_ms: number; + ts: number; + }; +} + /** * Raw SDK message forwarded from the container. * The container is a dumb pipe — every SDK message is forwarded as-is. @@ -116,6 +132,7 @@ export type ContainerEvent = | ContainerStateEvent | ContainerResultEvent | ContainerErrorEvent + | ContainerTokenUsageEvent | ContainerSdkMessageEvent; export interface ContainerOutput { diff --git a/src/db.test.ts b/src/db.test.ts index b33a1d4adec..2c78c10d481 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -527,3 +527,38 @@ describe('registered group isMain', () => { expect(group.isMain).toBeUndefined(); }); }); + +// --- Token usage --- + +describe('recordTokenUsage', () => { + it('stores a token usage row with all expected columns', () => { + db.recordTokenUsage({ + group_jid: 'group@g.us', + session_id: 'session-123', + model: 'claude-sonnet-4-6', + prompt_tokens: 1200, + completion_tokens: 300, + cache_read_tokens: 50, + cache_write_tokens: 25, + latency_ms: 987, + ts: 1_710_000_000_000, + }); + + const rows = db._getTokenUsageRowsForTests(); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + id: 1, + group_jid: 'group@g.us', + session_id: 'session-123', + model: 'claude-sonnet-4-6', + prompt_tokens: 1200, + completion_tokens: 300, + total_tokens: 1500, + cache_read_tokens: 50, + cache_write_tokens: 25, + latency_ms: 987, + ts: 1_710_000_000_000, + }); + expect(rows[0]?.cost_usd).toBeCloseTo(0.00834375); + }); +}); diff --git a/src/db.ts b/src/db.ts index 5965c9cd244..5c1600b5eed 100644 --- a/src/db.ts +++ b/src/db.ts @@ -4,6 +4,7 @@ import path from 'path'; import { isValidGroupFolder } from './group-folder.js'; import { logger } from './logger.js'; +import { computeCostUsd } from './token-pricing.js'; import { NewMessage, RegisteredGroup, @@ -11,6 +12,95 @@ import { TaskRunLog, } from './types.js'; +export interface TokenUsageRecordInput { + group_jid: string; + session_id?: string | null; + model: string; + prompt_tokens: number; + completion_tokens: number; + total_tokens?: number; + cache_read_tokens?: number; + cache_write_tokens?: number; + cost_usd?: number | null; + latency_ms?: number | null; + ts: number; +} + +export interface TokenUsageRow { + id: number; + group_jid: string; + session_id: string | null; + model: string; + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + cache_read_tokens: number; + cache_write_tokens: number; + cost_usd: number | null; + latency_ms: number | null; + ts: number; +} + +export interface TokenUsageSummaryFilters { + group_jid?: string; + model?: string; + since?: number; +} + +export interface TokenUsageSummaryByModel { + model: string; + request_count: number; + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + cost_usd: number | null; +} + +export interface TokenUsageSummaryBySession { + session_id: string | null; + request_count: number; + total_tokens: number; + cost_usd: number | null; +} + +export interface TokenUsageSummary { + total_tokens: number; + prompt_tokens: number; + completion_tokens: number; + cache_read_tokens: number; + cache_write_tokens: number; + total_cost_usd: number | null; + request_count: number; + by_model: TokenUsageSummaryByModel[]; + by_session: TokenUsageSummaryBySession[]; +} + +function buildTokenUsageWhere(filters: TokenUsageSummaryFilters): { + whereClause: string; + params: unknown[]; +} { + const clauses: string[] = []; + const params: unknown[] = []; + + if (filters.group_jid) { + clauses.push('group_jid = ?'); + params.push(filters.group_jid); + } + if (filters.model) { + clauses.push('model = ?'); + params.push(filters.model); + } + if (filters.since !== undefined) { + clauses.push('ts > ?'); + params.push(filters.since); + } + + return { + whereClause: clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '', + params, + }; +} + export function createSchema( database: Database.Database, assistantName?: string, @@ -82,6 +172,20 @@ export function createSchema( container_config TEXT, requires_trigger INTEGER DEFAULT 1 ); + CREATE TABLE IF NOT EXISTS token_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_jid TEXT NOT NULL, + session_id TEXT, + model TEXT NOT NULL, + prompt_tokens INTEGER NOT NULL DEFAULT 0, + completion_tokens INTEGER NOT NULL DEFAULT 0, + total_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0, + cost_usd REAL, + latency_ms INTEGER, + ts INTEGER NOT NULL + ); `); @@ -142,6 +246,12 @@ export function createSchema( } catch { /* columns already exist */ } + + database.exec(` + CREATE INDEX IF NOT EXISTS idx_token_usage_group_jid ON token_usage(group_jid); + CREATE INDEX IF NOT EXISTS idx_token_usage_ts ON token_usage(ts); + CREATE INDEX IF NOT EXISTS idx_token_usage_model ON token_usage(model); + `); } export function initDatabase(opts: { @@ -566,6 +676,142 @@ export class AgentDb { return result; } + // --- Token usage --- + + recordTokenUsage(usage: TokenUsageRecordInput): void { + const promptTokens = usage.prompt_tokens; + const completionTokens = usage.completion_tokens; + const cacheReadTokens = usage.cache_read_tokens ?? 0; + const cacheWriteTokens = usage.cache_write_tokens ?? 0; + const totalTokens = usage.total_tokens ?? promptTokens + completionTokens; + const costUsd = + usage.cost_usd ?? + computeCostUsd({ + model: usage.model, + promptTokens, + completionTokens, + cacheReadTokens, + cacheWriteTokens, + }) ?? + null; + + this.db + .prepare( + ` + INSERT INTO token_usage ( + group_jid, + session_id, + model, + prompt_tokens, + completion_tokens, + total_tokens, + cache_read_tokens, + cache_write_tokens, + cost_usd, + latency_ms, + ts + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + usage.group_jid, + usage.session_id ?? null, + usage.model, + promptTokens, + completionTokens, + totalTokens, + cacheReadTokens, + cacheWriteTokens, + costUsd, + usage.latency_ms ?? null, + usage.ts, + ); + } + + getTokenUsageSummary( + filters: TokenUsageSummaryFilters = {}, + ): TokenUsageSummary { + const { whereClause, params } = buildTokenUsageWhere(filters); + + const totals = this.db + .prepare( + ` + SELECT + COALESCE(SUM(total_tokens), 0) AS total_tokens, + COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens, + COALESCE(SUM(completion_tokens), 0) AS completion_tokens, + COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, + COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens, + SUM(cost_usd) AS total_cost_usd, + COUNT(*) AS request_count + FROM token_usage + ${whereClause} + `, + ) + .get(...params) as { + total_tokens: number; + prompt_tokens: number; + completion_tokens: number; + cache_read_tokens: number; + cache_write_tokens: number; + total_cost_usd: number | null; + request_count: number; + }; + + const byModel = this.db + .prepare( + ` + SELECT + model, + COUNT(*) AS request_count, + COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens, + COALESCE(SUM(completion_tokens), 0) AS completion_tokens, + COALESCE(SUM(total_tokens), 0) AS total_tokens, + SUM(cost_usd) AS cost_usd + FROM token_usage + ${whereClause} + GROUP BY model + ORDER BY request_count DESC, total_tokens DESC, model ASC + `, + ) + .all(...params) as TokenUsageSummaryByModel[]; + + const bySession = this.db + .prepare( + ` + SELECT + session_id, + COUNT(*) AS request_count, + COALESCE(SUM(total_tokens), 0) AS total_tokens, + SUM(cost_usd) AS cost_usd + FROM token_usage + ${whereClause} + GROUP BY session_id + ORDER BY request_count DESC, total_tokens DESC, session_id ASC + `, + ) + .all(...params) as TokenUsageSummaryBySession[]; + + return { + total_tokens: totals.total_tokens, + prompt_tokens: totals.prompt_tokens, + completion_tokens: totals.completion_tokens, + cache_read_tokens: totals.cache_read_tokens, + cache_write_tokens: totals.cache_write_tokens, + total_cost_usd: totals.total_cost_usd, + request_count: totals.request_count, + by_model: byModel, + by_session: bySession, + }; + } + + /** @internal - for tests only. */ + _getTokenUsageRowsForTests(): TokenUsageRow[] { + return this.db + .prepare('SELECT * FROM token_usage ORDER BY id') + .all() as TokenUsageRow[]; + } + // --- Registered groups --- getRegisteredGroup( diff --git a/src/token-pricing.test.ts b/src/token-pricing.test.ts new file mode 100644 index 00000000000..b64b42bb3ef --- /dev/null +++ b/src/token-pricing.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { computeCostUsd } from './token-pricing.js'; + +describe('computeCostUsd', () => { + it('returns the configured price for claude-opus-4-6', () => { + expect( + computeCostUsd({ + model: 'claude-opus-4-6', + promptTokens: 1_000_000, + completionTokens: 1_000_000, + cacheReadTokens: 1_000_000, + cacheWriteTokens: 1_000_000, + }), + ).toBe(110.25); + }); + + it('returns the configured price for claude-sonnet-4-6', () => { + expect( + computeCostUsd({ + model: 'claude-sonnet-4-6', + promptTokens: 1_000_000, + completionTokens: 1_000_000, + cacheReadTokens: 1_000_000, + cacheWriteTokens: 1_000_000, + }), + ).toBe(22.05); + }); + + it('returns the configured price for claude-haiku-4-5', () => { + expect( + computeCostUsd({ + model: 'claude-haiku-4-5', + promptTokens: 1_000_000, + completionTokens: 1_000_000, + cacheReadTokens: 1_000_000, + cacheWriteTokens: 1_000_000, + }), + ).toBe(5.88); + }); + + it('returns undefined for unknown models', () => { + expect( + computeCostUsd({ + model: 'unknown-model', + promptTokens: 123, + completionTokens: 456, + }), + ).toBeUndefined(); + }); +}); diff --git a/src/token-pricing.ts b/src/token-pricing.ts new file mode 100644 index 00000000000..8c1eaeaab70 --- /dev/null +++ b/src/token-pricing.ts @@ -0,0 +1,52 @@ +// Cost per 1M tokens in USD +export const MODEL_PRICING: Record< + string, + { + inputPer1M: number; + outputPer1M: number; + cacheReadPer1M?: number; + cacheWritePer1M?: number; + } +> = { + 'claude-opus-4-6': { + inputPer1M: 15.0, + outputPer1M: 75.0, + cacheReadPer1M: 1.5, + cacheWritePer1M: 18.75, + }, + 'claude-sonnet-4-6': { + inputPer1M: 3.0, + outputPer1M: 15.0, + cacheReadPer1M: 0.3, + cacheWritePer1M: 3.75, + }, + 'claude-haiku-4-5': { + inputPer1M: 0.8, + outputPer1M: 4.0, + cacheReadPer1M: 0.08, + cacheWritePer1M: 1.0, + }, +}; + +export function computeCostUsd(params: { + model: string; + promptTokens: number; + completionTokens: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; +}): number | undefined { + const pricing = MODEL_PRICING[params.model]; + if (!pricing) return undefined; + + const inputCost = (params.promptTokens / 1_000_000) * pricing.inputPer1M; + const outputCost = + (params.completionTokens / 1_000_000) * pricing.outputPer1M; + const cacheRead = pricing.cacheReadPer1M + ? ((params.cacheReadTokens ?? 0) / 1_000_000) * pricing.cacheReadPer1M + : 0; + const cacheWrite = pricing.cacheWritePer1M + ? ((params.cacheWriteTokens ?? 0) / 1_000_000) * pricing.cacheWritePer1M + : 0; + + return inputCost + outputCost + cacheRead + cacheWrite; +} diff --git a/src/token-usage-runtime.test.ts b/src/token-usage-runtime.test.ts new file mode 100644 index 00000000000..8510f3aefdd --- /dev/null +++ b/src/token-usage-runtime.test.ts @@ -0,0 +1,156 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./container-runner.js', async () => { + const actual = await vi.importActual( + './container-runner.js', + ); + return { + ...actual, + runContainerAgent: vi.fn(), + }; +}); + +import { AgentImpl } from './agent/agent-impl.js'; +import { + buildAgentConfig, + resolveSerializableAgentSettings, +} from './agent/config.js'; +import { _initTestDatabase, AgentDb } from './db.js'; +import { buildRuntimeConfig } from './runtime-config.js'; +import { runContainerAgent } from './container-runner.js'; +import type { Channel, RegisteredGroup } from './types.js'; + +const runtimeConfig = buildRuntimeConfig( + { timezone: 'UTC' }, + '/tmp/agentlite-test-pkg', +); + +const MAIN_GROUP: RegisteredGroup = { + name: 'Main', + folder: 'main', + trigger: 'always', + added_at: '2024-01-01T00:00:00.000Z', + isMain: true, +}; + +let tmpDir: string; +let db: AgentDb; + +function createAgent(name: string): AgentImpl { + const config = buildAgentConfig({ + agentId: `${name}00000000`.slice(0, 8), + ...resolveSerializableAgentSettings( + name, + { workdir: path.join(tmpDir, 'agents', name) }, + tmpDir, + ), + }); + return new AgentImpl(config, runtimeConfig); +} + +function createMockChannel(): Channel { + return { + name: 'mock', + async connect(): Promise {}, + async disconnect(): Promise {}, + async sendMessage(): Promise {}, + isConnected(): boolean { + return true; + }, + ownsJid(jid: string): boolean { + return jid === 'mock:token-usage'; + }, + async setTyping(): Promise {}, + }; +} + +describe('token usage runtime persistence', () => { + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentlite-token-usage-')); + db = _initTestDatabase(); + vi.mocked(runContainerAgent).mockReset(); + }); + + afterEach(() => { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + }); + + it('persists token usage emitted by the container runtime', async () => { + const agent = createAgent('token-usage'); + agent._setDbForTests(db); + agent._setRegisteredGroups({ 'mock:token-usage': MAIN_GROUP }); + (agent as unknown as { _started: boolean })._started = true; + (agent as unknown as { channels: Map }).channels.set( + 'mock', + createMockChannel(), + ); + + db.storeChatMetadata( + 'mock:token-usage', + '2026-04-19T00:00:00.000Z', + 'Token Usage Chat', + ); + db.storeMessage({ + id: 'msg-1', + chat_jid: 'mock:token-usage', + sender: 'user1', + sender_name: 'User 1', + content: 'track this run', + timestamp: '2026-04-19T00:00:01.000Z', + is_from_me: false, + }); + + vi.mocked(runContainerAgent).mockImplementation( + async (_group, _input, _rc, _onProcess, onOutput) => { + await onOutput?.({ + type: 'token_usage', + usage: { + group_jid: 'mock:token-usage', + session_id: 'session-runtime', + model: 'claude-sonnet-4-6', + prompt_tokens: 111, + completion_tokens: 22, + total_tokens: 133, + cache_read_tokens: 7, + cache_write_tokens: 3, + latency_ms: 456, + ts: 1_710_000_000_123, + }, + }); + await onOutput?.({ + type: 'result', + result: 'done', + newSessionId: 'session-runtime', + }); + return { status: 'success', result: 'done', newSessionId: 'session-runtime' }; + }, + ); + + const processed = await agent.processGroupMessages('mock:token-usage'); + expect(processed).toBe(true); + + const rows = db._getTokenUsageRowsForTests(); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + group_jid: 'mock:token-usage', + session_id: 'session-runtime', + model: 'claude-sonnet-4-6', + prompt_tokens: 111, + completion_tokens: 22, + total_tokens: 133, + cache_read_tokens: 7, + cache_write_tokens: 3, + latency_ms: 456, + ts: 1_710_000_000_123, + }); + expect(rows[0]?.cost_usd).toBeCloseTo(0.00067995); + }); +}); From 73772fa8cc7bdf436bba965632cbfece57cc65ef Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sun, 19 Apr 2026 15:48:20 +0800 Subject: [PATCH 02/21] chore: apply runtime test formatting --- src/token-usage-runtime.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/token-usage-runtime.test.ts b/src/token-usage-runtime.test.ts index 8510f3aefdd..761cf09e313 100644 --- a/src/token-usage-runtime.test.ts +++ b/src/token-usage-runtime.test.ts @@ -130,7 +130,11 @@ describe('token usage runtime persistence', () => { result: 'done', newSessionId: 'session-runtime', }); - return { status: 'success', result: 'done', newSessionId: 'session-runtime' }; + return { + status: 'success', + result: 'done', + newSessionId: 'session-runtime', + }; }, ); From db7ca089efdb386e0fcd86672b04cca4f7f0f22a Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sun, 19 Apr 2026 17:03:53 +0800 Subject: [PATCH 03/21] test: mock box runtime in usage action tests --- src/agent/usage-actions.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/agent/usage-actions.test.ts b/src/agent/usage-actions.test.ts index ccd1633cd89..35af86b1834 100644 --- a/src/agent/usage-actions.test.ts +++ b/src/agent/usage-actions.test.ts @@ -2,7 +2,21 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +vi.mock('../box-runtime.js', () => ({ + setBoxliteHome: vi.fn(), + ensureRuntimeReady: vi.fn(), + cleanupOrphans: vi.fn().mockResolvedValue(undefined), + spawnBox: vi.fn(), +})); import { createAgentLite } from '../api/sdk.js'; import type { Agent } from '../api/agent.js'; From 693aa080ca9e5f6925a134a54bb706039a9245a7 Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sun, 19 Apr 2026 17:06:07 +0800 Subject: [PATCH 04/21] style: format usage action test --- src/agent/usage-actions.test.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/agent/usage-actions.test.ts b/src/agent/usage-actions.test.ts index 35af86b1834..ac3db977462 100644 --- a/src/agent/usage-actions.test.ts +++ b/src/agent/usage-actions.test.ts @@ -2,14 +2,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -import { - afterEach, - beforeEach, - describe, - expect, - it, - vi, -} from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../box-runtime.js', () => ({ setBoxliteHome: vi.fn(), From b95718f3d64268282abc6d47c447659d8eecb1d5 Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Thu, 23 Apr 2026 12:58:25 +0800 Subject: [PATCH 05/21] fix: scope usage_get_summary to caller group; remove incorrect model fallback --- container/agent-runner/src/index.ts | 3 - src/agent/usage-actions.test.ts | 101 ++++++++++++++++++++++++++-- src/agent/usage-actions.ts | 5 +- 3 files changed, 101 insertions(+), 8 deletions(-) diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 4020917704d..bfca8e0197b 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -197,9 +197,6 @@ function resolveUsageModel( if (currentModel && models.some(([model]) => model === currentModel)) { return currentModel; } - if (currentModel) { - return currentModel; - } if (models.length === 0) { return 'unknown'; } diff --git a/src/agent/usage-actions.test.ts b/src/agent/usage-actions.test.ts index ac3db977462..93b440bd44e 100644 --- a/src/agent/usage-actions.test.ts +++ b/src/agent/usage-actions.test.ts @@ -27,6 +27,7 @@ describe('usage_get_summary action', () => { let platform: AgentLite; let agent: AgentWithHttpAndDb; let token: string | undefined; + let mainToken: string | undefined; let url: string | undefined; beforeEach(async () => { @@ -41,6 +42,7 @@ describe('usage_get_summary action', () => { } url = info.url; token = agent.actionsHttp.mintContainerToken('test-group', false)?.token; + mainToken = agent.actionsHttp.mintContainerToken('main-group', true)?.token; }); afterEach(async () => { @@ -124,10 +126,50 @@ describe('usage_get_summary action', () => { return { status: res.status, result: json.result }; } + async function callSummaryAsMain(payload?: Record) { + const res = await fetch(`${url}/call`, { + method: 'POST', + headers: { + Authorization: `Bearer ${mainToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: 'usage_get_summary', + payload, + }), + }); + const json = (await res.json()) as { + result: { + total_tokens: number; + prompt_tokens: number; + completion_tokens: number; + cache_read_tokens: number; + cache_write_tokens: number; + total_cost_usd: number | null; + request_count: number; + by_model: Array<{ + model: string; + request_count: number; + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + cost_usd: number | null; + }>; + by_session: Array<{ + session_id: string | null; + request_count: number; + total_tokens: number; + cost_usd: number | null; + }>; + }; + }; + return { status: res.status, result: json.result }; + } + it('returns all rows when no filters are provided', async () => { seedUsageRows(); - const response = await callSummary(); + const response = await callSummaryAsMain(); expect(response.status).toBe(200); expect(response.result).toMatchObject({ total_tokens: 900, @@ -162,7 +204,7 @@ describe('usage_get_summary action', () => { it('filters rows by group_jid', async () => { seedUsageRows(); - const response = await callSummary({ group_jid: 'group-1@g.us' }); + const response = await callSummaryAsMain({ group_jid: 'group-1@g.us' }); expect(response.status).toBe(200); expect(response.result).toMatchObject({ total_tokens: 450, @@ -182,7 +224,7 @@ describe('usage_get_summary action', () => { it('filters rows by since timestamp', async () => { seedUsageRows(); - const response = await callSummary({ since: 1_500 }); + const response = await callSummaryAsMain({ since: 1_500 }); expect(response.status).toBe(200); expect(response.result).toMatchObject({ total_tokens: 750, @@ -196,7 +238,7 @@ describe('usage_get_summary action', () => { it('returns the correct by_model breakdown', async () => { seedUsageRows(); - const response = await callSummary(); + const response = await callSummaryAsMain(); expect(response.status).toBe(200); const sonnet = response.result.by_model.find( @@ -224,4 +266,55 @@ describe('usage_get_summary action', () => { }); expect(opus?.cost_usd).toBeCloseTo(0.01575); }); + + it('non-main caller is scoped to its own group', async () => { + // Seed rows: one for 'test-group' (matches non-main token sourceGroup) + // and one for another group (should be hidden from non-main caller) + agent.db.recordTokenUsage({ + group_jid: 'test-group', + session_id: 'session-local', + model: 'claude-haiku-4-5', + prompt_tokens: 50, + completion_tokens: 25, + latency_ms: 50, + ts: 1_000, + }); + agent.db.recordTokenUsage({ + group_jid: 'other-group', + session_id: 'session-other', + model: 'claude-opus-4-6', + prompt_tokens: 500, + completion_tokens: 250, + latency_ms: 200, + ts: 2_000, + }); + + // Non-main token is bound to 'test-group' — should only see its own rows + const response = await callSummary(); + expect(response.status).toBe(200); + expect(response.result.request_count).toBe(1); + expect(response.result.total_tokens).toBe(75); + // Main token sees everything + const mainResponse = await callSummaryAsMain(); + expect(mainResponse.result.request_count).toBe(2); + }); + + it('filters rows by model', async () => { + seedUsageRows(); + + const response = await callSummaryAsMain({ model: 'claude-opus-4-6' }); + expect(response.status).toBe(200); + expect(response.result).toMatchObject({ + total_tokens: 450, + prompt_tokens: 300, + completion_tokens: 150, + request_count: 1, + }); + expect(response.result.by_model).toHaveLength(1); + expect(response.result.by_model[0]).toMatchObject({ + model: 'claude-opus-4-6', + request_count: 1, + total_tokens: 450, + }); + }); }); diff --git a/src/agent/usage-actions.ts b/src/agent/usage-actions.ts index 9fc981fc922..af8afe9e7c3 100644 --- a/src/agent/usage-actions.ts +++ b/src/agent/usage-actions.ts @@ -24,6 +24,9 @@ export function registerUsageActions(agent: Agent, db: AgentDb): void { 'Only include rows with a timestamp strictly greater than this Unix timestamp in milliseconds', ), }, - async (args) => db.getTokenUsageSummary(args), + async (args, ctx) => { + const effectiveFilters = ctx.isMain ? args : { ...args, group_jid: ctx.sourceGroup }; + return db.getTokenUsageSummary(effectiveFilters); + }, ); } From d9613b10bf70da5ecd9d19b56c042c6e988a70e2 Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Fri, 24 Apr 2026 23:56:31 +0800 Subject: [PATCH 06/21] fix: scope usage_get_summary to sourceGroup, fix resolveUsageModel fallback, add isolation tests --- container/agent-runner/src/index.ts | 38 +++++++++++++++++------------ src/agent/usage-actions.ts | 4 ++- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index bfca8e0197b..1b46431a550 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -189,14 +189,14 @@ function log(message: string): void { function resolveUsageModel( currentModel: string | undefined, modelUsage: SDKResultMessage['modelUsage'], -): string { +): string | null { const models = Object.entries(modelUsage); + if (currentModel) { + return models.some(([model]) => model === currentModel) ? currentModel : null; + } if (models.length === 1) { return models[0]![0]; } - if (currentModel && models.some(([model]) => model === currentModel)) { - return currentModel; - } if (models.length === 0) { return 'unknown'; } @@ -217,18 +217,23 @@ function captureTokenUsageSummary(params: { currentModel: string | undefined; queryStartedAt: number; message: SDKResultMessage; -}): ContainerTokenUsageOutput['usage'] { +}): ContainerTokenUsageOutput['usage'] | null { const usage = params.message.usage; const promptTokens = usage.input_tokens ?? 0; const completionTokens = usage.output_tokens ?? 0; const cacheReadTokens = usage.cache_read_input_tokens ?? 0; const cacheWriteTokens = usage.cache_creation_input_tokens ?? 0; const ts = Date.now(); + const model = resolveUsageModel(params.currentModel, params.message.modelUsage); + + if (!model) { + return null; + } return { group_jid: params.groupJid, session_id: params.sessionId ?? null, - model: resolveUsageModel(params.currentModel, params.message.modelUsage), + model, prompt_tokens: promptTokens, completion_tokens: completionTokens, total_tokens: promptTokens + completionTokens, @@ -693,16 +698,19 @@ async function runQuery( // ── Backward-compat: emit result for host message delivery ─ if (message.type === 'result') { resultCount++; - writeOutput({ - type: 'token_usage', - usage: captureTokenUsageSummary({ - groupJid: containerInput.chatJid, - sessionId: newSessionId ?? sessionId, - currentModel, - queryStartedAt, - message, - }), + const usageSummary = captureTokenUsageSummary({ + groupJid: containerInput.chatJid, + sessionId: newSessionId ?? sessionId, + currentModel, + queryStartedAt, + message, }); + if (usageSummary) { + writeOutput({ + type: 'token_usage', + usage: usageSummary, + }); + } const textResult = 'result' in message ? (message as { result?: string }).result : null; diff --git a/src/agent/usage-actions.ts b/src/agent/usage-actions.ts index af8afe9e7c3..ea78f9fb924 100644 --- a/src/agent/usage-actions.ts +++ b/src/agent/usage-actions.ts @@ -25,7 +25,9 @@ export function registerUsageActions(agent: Agent, db: AgentDb): void { ), }, async (args, ctx) => { - const effectiveFilters = ctx.isMain ? args : { ...args, group_jid: ctx.sourceGroup }; + const effectiveFilters = ctx.isMain + ? args + : { ...args, group_jid: ctx.sourceGroup }; return db.getTokenUsageSummary(effectiveFilters); }, ); From e9c29bd000c1bf85e7afcd9ca5ca89d0498025b7 Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sat, 25 Apr 2026 00:02:18 +0800 Subject: [PATCH 07/21] Fix token usage scoping and model attribution --- container/agent-runner/src/index.test.ts | 56 ++++++++++++++++++++++++ container/agent-runner/src/index.ts | 14 +++--- container/agent-runner/tsconfig.json | 2 +- src/agent/usage-actions.test.ts | 17 ++++--- vitest.config.ts | 6 ++- 5 files changed, 78 insertions(+), 17 deletions(-) create mode 100644 container/agent-runner/src/index.test.ts diff --git a/container/agent-runner/src/index.test.ts b/container/agent-runner/src/index.test.ts new file mode 100644 index 00000000000..6767c934811 --- /dev/null +++ b/container/agent-runner/src/index.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveUsageModel } from './index.js'; + +describe('resolveUsageModel', () => { + it('returns the only model when modelUsage has one entry', () => { + expect( + resolveUsageModel('claude-opus-4-6', { + 'claude-sonnet-4-6': { + inputTokens: 100, + outputTokens: 50, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + }, + }), + ).toBe('claude-sonnet-4-6'); + }); + + it('does not use currentModel when it is missing from modelUsage', () => { + expect( + resolveUsageModel('claude-opus-4-6', { + 'claude-haiku-4-5': { + inputTokens: 25, + outputTokens: 10, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + }, + 'claude-sonnet-4-6': { + inputTokens: 100, + outputTokens: 50, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + }, + }), + ).toBe('claude-sonnet-4-6'); + }); + + it('returns the highest-token model for multi-model usage', () => { + expect( + resolveUsageModel('claude-haiku-4-5', { + 'claude-haiku-4-5': { + inputTokens: 10, + outputTokens: 10, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + }, + 'claude-sonnet-4-6': { + inputTokens: 40, + outputTokens: 20, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + }, + }), + ).toBe('claude-sonnet-4-6'); + }); +}); diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 1b46431a550..c83a80fb075 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -186,14 +186,11 @@ function log(message: string): void { console.error(`[agent-runner] ${message}`); } -function resolveUsageModel( - currentModel: string | undefined, +export function resolveUsageModel( + _currentModel: string | undefined, modelUsage: SDKResultMessage['modelUsage'], ): string | null { const models = Object.entries(modelUsage); - if (currentModel) { - return models.some(([model]) => model === currentModel) ? currentModel : null; - } if (models.length === 1) { return models[0]![0]; } @@ -855,4 +852,9 @@ async function main(): Promise { } } -main(); +if ( + process.argv[1] && + fileURLToPath(import.meta.url) === path.resolve(process.argv[1]) +) { + void main(); +} diff --git a/container/agent-runner/tsconfig.json b/container/agent-runner/tsconfig.json index de6431e6359..20cc0d13718 100644 --- a/container/agent-runner/tsconfig.json +++ b/container/agent-runner/tsconfig.json @@ -11,5 +11,5 @@ "declaration": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] } diff --git a/src/agent/usage-actions.test.ts b/src/agent/usage-actions.test.ts index 93b440bd44e..6b677727188 100644 --- a/src/agent/usage-actions.test.ts +++ b/src/agent/usage-actions.test.ts @@ -268,8 +268,6 @@ describe('usage_get_summary action', () => { }); it('non-main caller is scoped to its own group', async () => { - // Seed rows: one for 'test-group' (matches non-main token sourceGroup) - // and one for another group (should be hidden from non-main caller) agent.db.recordTokenUsage({ group_jid: 'test-group', session_id: 'session-local', @@ -289,12 +287,13 @@ describe('usage_get_summary action', () => { ts: 2_000, }); - // Non-main token is bound to 'test-group' — should only see its own rows - const response = await callSummary(); + const response = await callSummary({ group_jid: 'other-group' }); expect(response.status).toBe(200); expect(response.result.request_count).toBe(1); expect(response.result.total_tokens).toBe(75); - // Main token sees everything + expect(response.result.by_session).toHaveLength(1); + expect(response.result.by_session[0]?.session_id).toBe('session-local'); + const mainResponse = await callSummaryAsMain(); expect(mainResponse.result.request_count).toBe(2); }); @@ -302,18 +301,18 @@ describe('usage_get_summary action', () => { it('filters rows by model', async () => { seedUsageRows(); - const response = await callSummaryAsMain({ model: 'claude-opus-4-6' }); + const response = await callSummaryAsMain({ model: 'claude-sonnet-4-6' }); expect(response.status).toBe(200); expect(response.result).toMatchObject({ total_tokens: 450, prompt_tokens: 300, completion_tokens: 150, - request_count: 1, + request_count: 2, }); expect(response.result.by_model).toHaveLength(1); expect(response.result.by_model[0]).toMatchObject({ - model: 'claude-opus-4-6', - request_count: 1, + model: 'claude-sonnet-4-6', + request_count: 2, total_tokens: 450, }); }); diff --git a/vitest.config.ts b/vitest.config.ts index a456d1cc3df..d5683466a01 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,10 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - include: ['src/**/*.test.ts', 'setup/**/*.test.ts'], + include: [ + 'src/**/*.test.ts', + 'setup/**/*.test.ts', + 'container/agent-runner/src/**/*.test.ts', + ], }, }); From 23262a9b8ae6f91b467d0b8c3319fd6cf0153b32 Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sat, 25 Apr 2026 00:20:36 +0800 Subject: [PATCH 08/21] Fix test build prerequisites --- package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 83bb614f626..ccb1573ea34 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ } }, "scripts": { - "build": "tsc && tsup", + "build": "tsc && tsup && npm run build:agent-runner", + "build:agent-runner": "npm --prefix container/agent-runner ci && npm --prefix container/agent-runner run build", "start": "node dist/cli.js", "dev": "tsx src/cli.ts | pino-pretty", "typecheck": "tsc --noEmit", @@ -51,7 +52,7 @@ "test:e2e:tasks": "tsx scripts/test-task-sdk-e2e.ts", "lint": "eslint src/", "lint:fix": "eslint src/ --fix", - "test": "vitest run", + "test": "npm run build && vitest run", "test:watch": "vitest" }, "dependencies": { From 10e4634fca45e51d17f6521460e01ec939302733 Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sat, 25 Apr 2026 01:20:32 +0800 Subject: [PATCH 09/21] Fix usage test path filtering --- package.json | 2 +- scripts/vitest-run.mjs | 33 +++++++++++++++++++++++++++++++++ src/agent/usage-actions.test.ts | 2 +- 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 scripts/vitest-run.mjs diff --git a/package.json b/package.json index b987bd6406b..6a7ae4f98e5 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "test:e2e:tasks": "tsx scripts/test-task-sdk-e2e.ts", "lint": "eslint src/", "lint:fix": "eslint src/ --fix", - "test": "npm run build && vitest run", + "test": "npm run build && node scripts/vitest-run.mjs", "test:watch": "vitest" }, "dependencies": { diff --git a/scripts/vitest-run.mjs b/scripts/vitest-run.mjs new file mode 100644 index 00000000000..4a969dce5a2 --- /dev/null +++ b/scripts/vitest-run.mjs @@ -0,0 +1,33 @@ +import { spawnSync } from 'node:child_process'; + +const args = []; +const rawArgs = process.argv.slice(2); + +for (let i = 0; i < rawArgs.length; i += 1) { + const arg = rawArgs[i]; + if (arg === '--testPathPattern') { + const pattern = rawArgs[i + 1]; + if (pattern) { + args.push(pattern); + i += 1; + } + continue; + } + if (arg?.startsWith('--testPathPattern=')) { + const pattern = arg.slice('--testPathPattern='.length); + if (pattern) args.push(pattern); + continue; + } + args.push(arg); +} + +const result = spawnSync('vitest', ['run', ...args], { + stdio: 'inherit', + shell: process.platform === 'win32', +}); + +if (result.error) { + throw result.error; +} + +process.exit(result.status ?? 1); diff --git a/src/agent/usage-actions.test.ts b/src/agent/usage-actions.test.ts index 6b677727188..5f31881f3e0 100644 --- a/src/agent/usage-actions.test.ts +++ b/src/agent/usage-actions.test.ts @@ -267,7 +267,7 @@ describe('usage_get_summary action', () => { expect(opus?.cost_usd).toBeCloseTo(0.01575); }); - it('non-main caller is scoped to its own group', async () => { + it('scopes results to sourceGroup for non-main callers', async () => { agent.db.recordTokenUsage({ group_jid: 'test-group', session_id: 'session-local', From dd1c8b442b979e68dad59db1173dc52a9489cafa Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sat, 25 Apr 2026 01:23:45 +0800 Subject: [PATCH 10/21] Fix npm test path pattern forwarding --- scripts/vitest-run.mjs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scripts/vitest-run.mjs b/scripts/vitest-run.mjs index 4a969dce5a2..27afe7f21a8 100644 --- a/scripts/vitest-run.mjs +++ b/scripts/vitest-run.mjs @@ -2,6 +2,17 @@ import { spawnSync } from 'node:child_process'; const args = []; const rawArgs = process.argv.slice(2); +const npmTestPathPattern = process.env.npm_config_testpathpattern; + +if ( + npmTestPathPattern && + !rawArgs.some( + (arg) => + arg === '--testPathPattern' || arg?.startsWith('--testPathPattern='), + ) +) { + args.push(npmTestPathPattern); +} for (let i = 0; i < rawArgs.length; i += 1) { const arg = rawArgs[i]; From bd3c3fe89b7c1f835314c58e2cb2b0609766253e Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sat, 25 Apr 2026 01:33:14 +0800 Subject: [PATCH 11/21] fix: final CI fixes --- container/agent-runner/src/agent-backend.ts | 6 +++++- container/agent-runner/src/index.test.ts | 12 ++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/container/agent-runner/src/agent-backend.ts b/container/agent-runner/src/agent-backend.ts index 9c1b9a47af1..f0f98b10b89 100644 --- a/container/agent-runner/src/agent-backend.ts +++ b/container/agent-runner/src/agent-backend.ts @@ -305,9 +305,13 @@ export function waitForIpcMessage( } export function resolveUsageModel( - _currentModel: string | undefined, + currentModel: string | undefined, modelUsage: SDKResultMessage['modelUsage'], ): string | null { + if (currentModel) { + return currentModel; + } + const models = Object.entries(modelUsage); if (models.length === 1) { return models[0]![0]; diff --git a/container/agent-runner/src/index.test.ts b/container/agent-runner/src/index.test.ts index 6767c934811..4183a998ce9 100644 --- a/container/agent-runner/src/index.test.ts +++ b/container/agent-runner/src/index.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { resolveUsageModel } from './index.js'; describe('resolveUsageModel', () => { - it('returns the only model when modelUsage has one entry', () => { + it('returns the current model when available', () => { expect( resolveUsageModel('claude-opus-4-6', { 'claude-sonnet-4-6': { @@ -13,10 +13,10 @@ describe('resolveUsageModel', () => { cacheCreationInputTokens: 0, }, }), - ).toBe('claude-sonnet-4-6'); + ).toBe('claude-opus-4-6'); }); - it('does not use currentModel when it is missing from modelUsage', () => { + it('falls back to the current model when modelUsage keys do not match it', () => { expect( resolveUsageModel('claude-opus-4-6', { 'claude-haiku-4-5': { @@ -32,12 +32,12 @@ describe('resolveUsageModel', () => { cacheCreationInputTokens: 0, }, }), - ).toBe('claude-sonnet-4-6'); + ).toBe('claude-opus-4-6'); }); - it('returns the highest-token model for multi-model usage', () => { + it('falls back to the highest-token model when currentModel is unavailable', () => { expect( - resolveUsageModel('claude-haiku-4-5', { + resolveUsageModel(undefined, { 'claude-haiku-4-5': { inputTokens: 10, outputTokens: 10, From 3793d00285d119f3b1236e0695cd32af35cab1bb Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sat, 25 Apr 2026 01:40:31 +0800 Subject: [PATCH 12/21] Fix usage model attribution fallback --- container/agent-runner/src/agent-backend.ts | 25 ++++++++++++--------- container/agent-runner/src/index.test.ts | 12 ++++++---- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/container/agent-runner/src/agent-backend.ts b/container/agent-runner/src/agent-backend.ts index f0f98b10b89..b91bd0e9954 100644 --- a/container/agent-runner/src/agent-backend.ts +++ b/container/agent-runner/src/agent-backend.ts @@ -309,7 +309,9 @@ export function resolveUsageModel( modelUsage: SDKResultMessage['modelUsage'], ): string | null { if (currentModel) { - return currentModel; + return Object.prototype.hasOwnProperty.call(modelUsage, currentModel) + ? currentModel + : null; } const models = Object.entries(modelUsage); @@ -317,17 +319,15 @@ export function resolveUsageModel( return models[0]![0]; } if (models.length === 0) { - return 'unknown'; + return null; } - return models - .slice() - .sort((a, b) => { - const totalA = a[1].inputTokens + a[1].outputTokens; - const totalB = b[1].inputTokens + b[1].outputTokens; - if (totalA !== totalB) return totalB - totalA; - return a[0].localeCompare(b[0]); - })[0]![0]; + return models.slice().sort((a, b) => { + const totalA = a[1].inputTokens + a[1].outputTokens; + const totalB = b[1].inputTokens + b[1].outputTokens; + if (totalA !== totalB) return totalB - totalA; + return a[0].localeCompare(b[0]); + })[0]![0]; } function captureTokenUsageSummary(params: { @@ -343,7 +343,10 @@ function captureTokenUsageSummary(params: { const cacheReadTokens = usage.cache_read_input_tokens ?? 0; const cacheWriteTokens = usage.cache_creation_input_tokens ?? 0; const ts = Date.now(); - const model = resolveUsageModel(params.currentModel, params.message.modelUsage); + const model = resolveUsageModel( + params.currentModel, + params.message.modelUsage, + ); if (!model) { return null; diff --git a/container/agent-runner/src/index.test.ts b/container/agent-runner/src/index.test.ts index 4183a998ce9..017cc21a15b 100644 --- a/container/agent-runner/src/index.test.ts +++ b/container/agent-runner/src/index.test.ts @@ -3,10 +3,10 @@ import { describe, expect, it } from 'vitest'; import { resolveUsageModel } from './index.js'; describe('resolveUsageModel', () => { - it('returns the current model when available', () => { + it('returns the current model when it is present in modelUsage', () => { expect( resolveUsageModel('claude-opus-4-6', { - 'claude-sonnet-4-6': { + 'claude-opus-4-6': { inputTokens: 100, outputTokens: 50, cacheReadInputTokens: 0, @@ -16,7 +16,7 @@ describe('resolveUsageModel', () => { ).toBe('claude-opus-4-6'); }); - it('falls back to the current model when modelUsage keys do not match it', () => { + it('returns null when currentModel is not present in modelUsage', () => { expect( resolveUsageModel('claude-opus-4-6', { 'claude-haiku-4-5': { @@ -32,7 +32,7 @@ describe('resolveUsageModel', () => { cacheCreationInputTokens: 0, }, }), - ).toBe('claude-opus-4-6'); + ).toBeNull(); }); it('falls back to the highest-token model when currentModel is unavailable', () => { @@ -53,4 +53,8 @@ describe('resolveUsageModel', () => { }), ).toBe('claude-sonnet-4-6'); }); + + it('returns null when no usage model is available', () => { + expect(resolveUsageModel(undefined, {})).toBeNull(); + }); }); From 5be9b5c64d0eeb59c461da8242b396281a3f33cd Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sat, 25 Apr 2026 05:31:30 +0800 Subject: [PATCH 13/21] style: apply prettier formatting --- .claude/skills/add-compact/SKILL.md | 1 + .claude/skills/add-discord/SKILL.md | 6 + .claude/skills/add-gmail/SKILL.md | 1 + .claude/skills/add-image-vision/SKILL.md | 3 + .claude/skills/add-ollama-tool/SKILL.md | 4 + .claude/skills/add-parallel/SKILL.md | 69 ++- .claude/skills/add-pdf-reader/SKILL.md | 3 + .claude/skills/add-reactions/SKILL.md | 3 + .claude/skills/add-slack/SKILL.md | 7 + .claude/skills/add-telegram-swarm/SKILL.md | 49 ++- .claude/skills/add-telegram/SKILL.md | 6 + .../skills/add-voice-transcription/SKILL.md | 3 + .claude/skills/add-whatsapp/SKILL.md | 17 +- .../convert-to-apple-container/SKILL.md | 21 +- .claude/skills/customize/SKILL.md | 38 +- .claude/skills/debug/SKILL.md | 37 +- .claude/skills/get-qodo-rules/SKILL.md | 56 +-- .../references/output-format.md | 3 + .../get-qodo-rules/references/pagination.md | 4 +- .../references/repository-scope.md | 6 +- .claude/skills/qodo-pr-resolver/SKILL.md | 12 + .../qodo-pr-resolver/resources/providers.md | 15 +- .claude/skills/setup/SKILL.md | 18 +- .claude/skills/setup/diagnostics.md | 2 + .claude/skills/update-nanoclaw/SKILL.md | 54 ++- .claude/skills/update-nanoclaw/diagnostics.md | 2 + .claude/skills/update-skills/SKILL.md | 16 + .claude/skills/use-local-whisper/SKILL.md | 14 +- .../use-native-credential-proxy/SKILL.md | 2 + .claude/skills/x-integration/SKILL.md | 106 +++-- .claude/skills/x-integration/agent.ts | 129 ++++-- .claude/skills/x-integration/host.ts | 56 ++- .claude/skills/x-integration/lib/browser.ts | 77 +++- .claude/skills/x-integration/lib/config.ts | 5 +- .claude/skills/x-integration/scripts/like.ts | 14 +- .claude/skills/x-integration/scripts/post.ts | 39 +- .claude/skills/x-integration/scripts/quote.ts | 22 +- .claude/skills/x-integration/scripts/reply.ts | 18 +- .../skills/x-integration/scripts/retweet.ts | 18 +- .claude/skills/x-integration/scripts/setup.ts | 51 ++- .github/PULL_REQUEST_TEMPLATE.md | 2 +- .github/workflows/fork-sync-skills.yml | 4 +- CHANGELOG.md | 1 + CLAUDE.md | 57 +-- CONTRIBUTING.md | 24 +- README_ja.md | 8 + README_zh.md | 5 + config-examples/mount-allowlist.json | 6 +- container/agent-runner/src/ipc-mcp-stdio.ts | 218 ++++++++-- container/skills/capabilities/SKILL.md | 5 +- container/skills/slack-formatting/SKILL.md | 15 +- container/skills/status/SKILL.md | 3 +- docs/APPLE-CONTAINER-NETWORKING.md | 15 +- docs/DEBUG_CHECKLIST.md | 3 + docs/REQUIREMENTS.md | 21 + docs/SDK_DEEP_DIVE.md | 409 ++++++++++-------- docs/SECURITY.md | 51 ++- docs/boxlite-migrate-plan.md | 43 +- docs/docker-sandboxes.md | 17 + docs/skills-as-branches.md | 58 ++- eslint.config.js | 10 +- groups/global/CLAUDE.md | 2 + groups/main/CLAUDE.md | 15 +- repo-tokens/README.md | 32 +- scripts/test-boxlite-e2e.ts | 18 +- scripts/test-host-reach.ts | 5 +- scripts/test-mcp-in-container.ts | 19 +- scripts/test-sdk.ts | 6 +- setup/environment.test.ts | 1 - setup/groups.ts | 6 +- setup/service.ts | 1 - setup/verify.ts | 10 +- 72 files changed, 1432 insertions(+), 665 deletions(-) diff --git a/.claude/skills/add-compact/SKILL.md b/.claude/skills/add-compact/SKILL.md index ef9a57d6641..0e728c51460 100644 --- a/.claude/skills/add-compact/SKILL.md +++ b/.claude/skills/add-compact/SKILL.md @@ -31,6 +31,7 @@ git merge upstream/skill/compact > **Note:** `upstream` is the remote pointing to `qwibitai/agentlite`. If using a different remote name, substitute accordingly. This adds: + - `src/session-commands.ts` (extract and authorize session commands) - `src/session-commands.test.ts` (unit tests for command parsing and auth) - Session command interception in `src/orchestrator.ts` (both `processGroupMessages` and `startMessageLoop`) diff --git a/.claude/skills/add-discord/SKILL.md b/.claude/skills/add-discord/SKILL.md index 09f1b3fa4ed..a6ea15a4319 100644 --- a/.claude/skills/add-discord/SKILL.md +++ b/.claude/skills/add-discord/SKILL.md @@ -47,6 +47,7 @@ git merge discord/main || { ``` This merges in: + - `src/channels/discord.ts` (DiscordChannel class with self-registration via `registerChannel`) - `src/channels/discord.test.ts` (unit tests with discord.js mock) - `import './discord.js'` appended to the channel barrel file `src/channels/index.ts` @@ -151,6 +152,7 @@ npx tsx setup/index.ts --step register -- --jid "dc:" --name " Send a message in your registered Discord channel: +> > - For main channel: Any message works > - For non-main: @mention the bot in Discord > @@ -175,12 +177,14 @@ tail -f logs/agentlite.log ### Bot only responds to @mentions This is the default behavior for non-main channels (`requiresTrigger: true`). To change: + - Update the registered group's `requiresTrigger` to `false` - Or register the channel as the main channel ### Message Content Intent not enabled If the bot connects but can't read messages, ensure: + 1. Go to [Discord Developer Portal](https://discord.com/developers/applications) 2. Select your application > **Bot** tab 3. Under **Privileged Gateway Intents**, enable **Message Content Intent** @@ -189,12 +193,14 @@ If the bot connects but can't read messages, ensure: ### Getting Channel ID If you can't copy the channel ID: + - Ensure **Developer Mode** is enabled: User Settings > Advanced > Developer Mode - Right-click the channel name in the server sidebar > Copy Channel ID ## After Setup The Discord bot supports: + - Text messages in registered channels - Attachment descriptions (images, videos, files shown as placeholders) - Reply context (shows who the user is replying to) diff --git a/.claude/skills/add-gmail/SKILL.md b/.claude/skills/add-gmail/SKILL.md index 7dc89ee2930..16b4abab5da 100644 --- a/.claude/skills/add-gmail/SKILL.md +++ b/.claude/skills/add-gmail/SKILL.md @@ -48,6 +48,7 @@ git merge gmail/main || { ``` This merges in: + - `src/channels/gmail.ts` (GmailChannel class with self-registration via `registerChannel`) - `src/channels/gmail.test.ts` (unit tests) - `import './gmail.js'` appended to the channel barrel file `src/channels/index.ts` diff --git a/.claude/skills/add-image-vision/SKILL.md b/.claude/skills/add-image-vision/SKILL.md index e089f7a6cc1..3b59a657386 100644 --- a/.claude/skills/add-image-vision/SKILL.md +++ b/.claude/skills/add-image-vision/SKILL.md @@ -40,6 +40,7 @@ git merge whatsapp/skill/image-vision || { ``` This merges in: + - `src/image.ts` (image download, resize via sharp, base64 encoding) - `src/image.test.ts` (8 unit tests) - Image attachment handling in `src/channels/whatsapp.ts` @@ -62,11 +63,13 @@ All tests must pass and build must be clean before proceeding. ## Phase 3: Configure 1. Rebuild the container (agent-runner changes need a rebuild): + ```bash ./container/build.sh ``` 2. Sync agent-runner source to group caches: + ```bash for dir in data/sessions/*/agent-runner-src/; do cp container/agent-runner/src/*.ts "$dir" diff --git a/.claude/skills/add-ollama-tool/SKILL.md b/.claude/skills/add-ollama-tool/SKILL.md index 71dd1ceab1d..14efdb82088 100644 --- a/.claude/skills/add-ollama-tool/SKILL.md +++ b/.claude/skills/add-ollama-tool/SKILL.md @@ -8,6 +8,7 @@ description: Add Ollama MCP server so the container agent can call local models This skill adds a stdio-based MCP server that exposes local Ollama models as tools for the container agent. Claude remains the orchestrator but can offload work to local models. Tools added: + - `ollama_list_models` — lists installed Ollama models - `ollama_generate` — sends a prompt to a specified model and returns the response @@ -59,6 +60,7 @@ git merge upstream/skill/ollama-tool ``` This merges in: + - `container/agent-runner/src/ollama-mcp-stdio.ts` (Ollama MCP server) - `scripts/ollama-watch.sh` (macOS notification watcher) - Ollama MCP config in `container/agent-runner/src/index.ts` (allowedTools + mcpServers) @@ -129,6 +131,7 @@ tail -f logs/agentlite.log | grep -i ollama ``` Look for: + - `Agent output: ... Ollama ...` — agent used Ollama successfully - `[OLLAMA] >>> Generating` — generation started (if log surfacing works) - `[OLLAMA] <<< Done` — generation completed @@ -138,6 +141,7 @@ Look for: ### Agent says "Ollama is not installed" The agent is trying to run `ollama` CLI inside the container instead of using the MCP tools. This means: + 1. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `ollama` entry in `mcpServers` 2. The per-group source wasn't updated — re-copy files (see Phase 2) 3. The container wasn't rebuilt — run `./container/build.sh` diff --git a/.claude/skills/add-parallel/SKILL.md b/.claude/skills/add-parallel/SKILL.md index 645cdde89de..8fb86f88f24 100644 --- a/.claude/skills/add-parallel/SKILL.md +++ b/.claude/skills/add-parallel/SKILL.md @@ -11,6 +11,7 @@ Adds Parallel AI MCP integration to AgentLite for advanced web research capabili ## Prerequisites User must have: + 1. Parallel AI API key from https://platform.parallel.ai 2. AgentLite already set up and running 3. Docker installed and running @@ -28,6 +29,7 @@ Collect it now. **If they need one:** Tell them: + > 1. Go to https://platform.parallel.ai > 2. Sign up or log in > 3. Navigate to API Keys section @@ -58,6 +60,7 @@ fi ``` Verify: + ```bash grep "PARALLEL_API_KEY" .env | head -c 50 ``` @@ -67,13 +70,19 @@ grep "PARALLEL_API_KEY" .env | head -c 50 Add `PARALLEL_API_KEY` to allowed environment variables in `src/container-runner.ts`: Find the line: + ```typescript const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']; ``` Replace with: + ```typescript -const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY', 'PARALLEL_API_KEY']; +const allowedVars = [ + 'CLAUDE_CODE_OAUTH_TOKEN', + 'ANTHROPIC_API_KEY', + 'PARALLEL_API_KEY', +]; ``` ### 4. Configure MCP Servers in Agent Runner @@ -81,34 +90,36 @@ const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY', 'PARALLEL_A Update `container/agent-runner/src/index.ts`: Find the section where `mcpServers` is configured (around line 237-252): + ```typescript const mcpServers: Record = { - agentlite: ipcMcp + agentlite: ipcMcp, }; ``` Add Parallel AI MCP servers after the agentlite server: + ```typescript const mcpServers: Record = { - agentlite: ipcMcp + agentlite: ipcMcp, }; // Add Parallel AI MCP servers if API key is available const parallelApiKey = process.env.PARALLEL_API_KEY; if (parallelApiKey) { mcpServers['parallel-search'] = { - type: 'http', // REQUIRED: Must specify type for HTTP MCP servers + type: 'http', // REQUIRED: Must specify type for HTTP MCP servers url: 'https://search-mcp.parallel.ai/mcp', headers: { - 'Authorization': `Bearer ${parallelApiKey}` - } + Authorization: `Bearer ${parallelApiKey}`, + }, }; mcpServers['parallel-task'] = { - type: 'http', // REQUIRED: Must specify type for HTTP MCP servers + type: 'http', // REQUIRED: Must specify type for HTTP MCP servers url: 'https://task-mcp.parallel.ai/mcp', headers: { - 'Authorization': `Bearer ${parallelApiKey}` - } + Authorization: `Bearer ${parallelApiKey}`, + }, }; log('Parallel AI MCP servers configured'); } else { @@ -117,6 +128,7 @@ if (parallelApiKey) { ``` Also update the `allowedTools` array to include Parallel MCP tools (around line 242-248): + ```typescript allowedTools: [ 'Bash', @@ -133,20 +145,24 @@ allowedTools: [ Add Parallel AI usage instructions to `groups/main/CLAUDE.md`: Find the "## What You Can Do" section and add after the existing bullet points: + ```markdown - Use Parallel AI for web research and deep learning tasks ``` Then add a new section after "## What You Can Do": + ```markdown ## Web Research Tools You have access to two Parallel AI research tools: ### Quick Web Search (`mcp__parallel-search__search`) + **When to use:** Freely use for factual lookups, current events, definitions, recent information, or verifying facts. **Examples:** + - "Who invented the transistor?" - "What's the latest news about quantum computing?" - "When was the UN founded?" @@ -157,9 +173,11 @@ You have access to two Parallel AI research tools: **Permission:** Not needed - use whenever it helps answer the question ### Deep Research (`mcp__parallel-task__create_task_run`) + **When to use:** Comprehensive analysis, learning about complex topics, comparing concepts, historical overviews, or structured research. **Examples:** + - "Explain the development of quantum mechanics from 1900-1930" - "Compare the literary styles of Hemingway and Faulkner" - "Research the evolution of jazz from bebop to fusion" @@ -171,7 +189,9 @@ You have access to two Parallel AI research tools: **How to ask permission:** ``` + AskUserQuestion: I can do deep research on [topic] using Parallel's Task API. This will take 2-5 minutes and provide comprehensive analysis with citations. Should I proceed? + ``` **After permission - DO NOT BLOCK! Use scheduler instead:** @@ -179,20 +199,22 @@ AskUserQuestion: I can do deep research on [topic] using Parallel's Task API. Th 1. Create the task using `mcp__parallel-task__create_task_run` 2. Get the `run_id` from the response 3. Create a polling scheduled task using `mcp__agentlite__schedule_task`: - ``` - Prompt: "Check Parallel AI task run [run_id] and send results when ready. +``` + +Prompt: "Check Parallel AI task run [run_id] and send results when ready. - 1. Use the Parallel Task MCP to check the task status - 2. If status is 'completed', extract the results - 3. Send results to user with mcp__agentlite__send_message - 4. Use mcp__agentlite__complete_scheduled_task to mark this task as done +1. Use the Parallel Task MCP to check the task status +2. If status is 'completed', extract the results +3. Send results to user with mcp**agentlite**send_message +4. Use mcp**agentlite**complete_scheduled_task to mark this task as done - If status is still 'running' or 'pending', do nothing (task will run again in 30s). - If status is 'failed', send error message and complete the task." +If status is still 'running' or 'pending', do nothing (task will run again in 30s). +If status is 'failed', send error message and complete the task." - Schedule: interval every 30 seconds - Context mode: isolated - ``` +Schedule: interval every 30 seconds +Context mode: isolated + +``` 4. Send acknowledgment with tracking link 5. Exit immediately - scheduler handles the rest @@ -223,6 +245,7 @@ Build the container with updated agent runner: ``` Verify the build: + ```bash echo '{}' | docker run -i --entrypoint /bin/echo agentlite-agent:latest "Container OK" ``` @@ -238,6 +261,7 @@ launchctl kickstart -k gui/$(id -u)/com.agentlite # macOS ``` Wait 3 seconds for service to start, then verify: + ```bash sleep 3 launchctl list | grep agentlite # macOS @@ -247,6 +271,7 @@ launchctl list | grep agentlite # macOS ### 8. Test Integration Tell the user to test: + > Send a message to your assistant: `@[YourAssistantName] what's the latest news about AI?` > > The assistant should use Parallel Search API to find current information. @@ -256,6 +281,7 @@ Tell the user to test: > The assistant should ask for permission before using the Task API. Check logs to verify MCP servers loaded: + ```bash tail -20 logs/agentlite.log ``` @@ -265,16 +291,19 @@ Look for: `Parallel AI MCP servers configured` ## Troubleshooting **Container hangs or times out:** + - Check that `type: 'http'` is specified in MCP server config - Verify API key is correct in .env - Check container logs: `cat groups/main/logs/container-*.log | tail -50` **MCP servers not loading:** + - Ensure PARALLEL_API_KEY is in .env - Verify container-runner.ts includes PARALLEL_API_KEY in allowedVars - Check agent-runner logs for "Parallel AI MCP servers configured" message **Task polling not working:** + - Verify scheduled task was created: `sqlite3 store/messages.db "SELECT * FROM scheduled_tasks"` - Check task runs: `tail -f logs/agentlite.log | grep "scheduled task"` - Ensure task prompt includes proper Parallel MCP tool names diff --git a/.claude/skills/add-pdf-reader/SKILL.md b/.claude/skills/add-pdf-reader/SKILL.md index 522547c164b..a996b38a00d 100644 --- a/.claude/skills/add-pdf-reader/SKILL.md +++ b/.claude/skills/add-pdf-reader/SKILL.md @@ -38,6 +38,7 @@ git merge whatsapp/skill/pdf-reader || { ``` This merges in: + - `container/skills/pdf-reader/SKILL.md` (agent-facing documentation) - `container/skills/pdf-reader/pdf-reader` (CLI script) - `poppler-utils` in `container/Dockerfile` @@ -71,6 +72,7 @@ launchctl kickstart -k gui/$(id -u)/com.agentlite # macOS ### Test PDF extraction Send a PDF file in any registered WhatsApp chat. The agent should: + 1. Download the PDF to `attachments/` 2. Respond acknowledging the PDF 3. Be able to extract text when asked @@ -86,6 +88,7 @@ tail -f logs/agentlite.log | grep -i pdf ``` Look for: + - `Downloaded PDF attachment` — successful download - `Failed to download PDF attachment` — media download issue diff --git a/.claude/skills/add-reactions/SKILL.md b/.claude/skills/add-reactions/SKILL.md index a3a1f1d82d1..beb8d411ced 100644 --- a/.claude/skills/add-reactions/SKILL.md +++ b/.claude/skills/add-reactions/SKILL.md @@ -45,6 +45,7 @@ git merge whatsapp/skill/reactions || { ``` This adds: + - `scripts/migrate-reactions.ts` (database migration for `reactions` table with composite PK and indexes) - `src/status-tracker.ts` (forward-only emoji state machine for message lifecycle signaling, with persistence and retry) - `src/status-tracker.test.ts` (unit tests for StatusTracker) @@ -75,11 +76,13 @@ npm run build ``` Linux: + ```bash systemctl --user restart agentlite ``` macOS: + ```bash launchctl kickstart -k gui/$(id -u)/com.agentlite ``` diff --git a/.claude/skills/add-slack/SKILL.md b/.claude/skills/add-slack/SKILL.md index 59e6997a238..69c6cc37c25 100644 --- a/.claude/skills/add-slack/SKILL.md +++ b/.claude/skills/add-slack/SKILL.md @@ -43,6 +43,7 @@ git merge slack/main || { ``` This merges in: + - `src/channels/slack.ts` (SlackChannel class with self-registration via `registerChannel`) - `src/channels/slack.test.ts` (46 unit tests) - `import './slack.js'` appended to the channel barrel file `src/channels/index.ts` @@ -68,6 +69,7 @@ All tests must pass (including the new Slack tests) and build must be clean befo If the user doesn't have a Slack app, share [SLACK_SETUP.md](SLACK_SETUP.md) which has step-by-step instructions with screenshots guidance, troubleshooting, and a token reference table. Quick summary of what's needed: + 1. Create a Slack app at [api.slack.com/apps](https://api.slack.com/apps) 2. Enable Socket Mode and generate an App-Level Token (`xapp-...`) 3. Subscribe to bot events: `message.channels`, `message.groups`, `message.im` @@ -139,6 +141,7 @@ npx tsx setup/index.ts --step register -- --jid "slack:" --name " Send a message in your registered Slack channel: +> > - For main channel: Any message works > - For non-main: `@ hello` (using the configured trigger word) > @@ -169,12 +172,14 @@ tail -f logs/agentlite.log ### Bot not seeing messages in channels By default, bots only see messages in channels they've been explicitly added to. Make sure to: + 1. Add the bot to each channel you want it to monitor 2. Check the bot has `channels:history` and/or `groups:history` scopes ### "missing_scope" errors If the bot logs `missing_scope` errors: + 1. Go to **OAuth & Permissions** in your Slack app settings 2. Add the missing scope listed in the error message 3. **Reinstall the app** to your workspace — scope changes require reinstallation @@ -185,6 +190,7 @@ If the bot logs `missing_scope` errors: ### Getting channel ID If the channel ID is hard to find: + - In Slack desktop: right-click channel → **Copy link** → extract the `C...` ID from the URL - In Slack web: the URL shows `https://app.slack.com/client/TXXXXXXX/C0123456789` - Via API: `curl -s -H "Authorization: Bearer $SLACK_BOT_TOKEN" "https://slack.com/api/conversations.list" | jq '.channels[] | {id, name}'` @@ -192,6 +198,7 @@ If the channel ID is hard to find: ## After Setup The Slack channel supports: + - **Public channels** — Bot must be added to the channel - **Private channels** — Bot must be invited to the channel - **Direct messages** — Users can DM the bot directly diff --git a/.claude/skills/add-telegram-swarm/SKILL.md b/.claude/skills/add-telegram-swarm/SKILL.md index 9da28b2be54..450d94c21d4 100644 --- a/.claude/skills/add-telegram-swarm/SKILL.md +++ b/.claude/skills/add-telegram-swarm/SKILL.md @@ -50,6 +50,7 @@ Tell the user: > **Important**: Each pool bot needs Group Privacy disabled so it can send messages in groups. > > For each pool bot in `@BotFather`: +> > 1. Send `/mybots` and select the bot > 2. Go to **Bot Settings** > **Group Privacy** > **Turn off** > @@ -142,9 +143,15 @@ export async function sendPoolMessage( try { await poolApis[idx].setMyName(sender); await new Promise((r) => setTimeout(r, 2000)); - logger.info({ sender, groupFolder, poolIndex: idx }, 'Assigned and renamed pool bot'); + logger.info( + { sender, groupFolder, poolIndex: idx }, + 'Assigned and renamed pool bot', + ); } catch (err) { - logger.warn({ sender, err }, 'Failed to rename pool bot (sending anyway)'); + logger.warn( + { sender, err }, + 'Failed to rename pool bot (sending anyway)', + ); } } @@ -159,7 +166,10 @@ export async function sendPoolMessage( await api.sendMessage(numericId, text.slice(i, i + MAX_LENGTH)); } } - logger.info({ chatId, sender, poolIndex: idx, length: text.length }, 'Pool message sent'); + logger.info( + { chatId, sender, poolIndex: idx, length: text.length }, + 'Pool message sent', + ); } catch (err) { logger.error({ chatId, sender, err }, 'Failed to send pool message'); } @@ -171,11 +181,13 @@ export async function sendPoolMessage( Read `container/agent-runner/src/ipc-mcp-stdio.ts` and update the `send_message` tool to accept an optional `sender` parameter: Change the tool's schema from: + ```typescript { text: z.string().describe('The message text to send') }, ``` To: + ```typescript { text: z.string().describe('The message text to send'), @@ -212,12 +224,7 @@ Read `src/ipc.ts` and make these changes: ```typescript if (data.sender && data.chatJid.startsWith('tg:')) { - await sendPoolMessage( - data.chatJid, - data.text, - data.sender, - sourceGroup, - ); + await sendPoolMessage(data.chatJid, data.text, data.sender, sourceGroup); } else { await deps.sendMessage(data.chatJid, data.text); } @@ -243,10 +250,11 @@ Read `groups/global/CLAUDE.md` and add a Message Formatting section: ## Message Formatting NEVER use markdown. Only use WhatsApp/Telegram formatting: -- *single asterisks* for bold (NEVER **double asterisks**) + +- _single asterisks_ for bold (NEVER **double asterisks**) - _underscores_ for italic - • bullet points -- ```triple backticks``` for code +- `triple backticks` for code No ## headings. No [links](url). No **double stars**. ``` @@ -263,31 +271,31 @@ In any group CLAUDE.md that has a "WhatsApp Formatting" section (e.g. `groups/ma For each Telegram group that will use agent teams, create or update its `groups/{folder}/CLAUDE.md` with these instructions. Read the existing CLAUDE.md first (or `groups/global/CLAUDE.md` as a base) and add the Agent Teams section: -```markdown +````markdown ## Agent Teams When creating a team to tackle a complex task, follow these rules: ### CRITICAL: Follow the user's prompt exactly -Create *exactly* the team the user asked for — same number of agents, same roles, same names. Do NOT add extra agents, rename roles, or use generic names like "Researcher 1". If the user says "a marine biologist, a physicist, and Alexander Hamilton", create exactly those three agents with those exact names. +Create _exactly_ the team the user asked for — same number of agents, same roles, same names. Do NOT add extra agents, rename roles, or use generic names like "Researcher 1". If the user says "a marine biologist, a physicist, and Alexander Hamilton", create exactly those three agents with those exact names. ### Team member instructions Each team member MUST be instructed to: -1. *Share progress in the group* via `mcp__agentlite__send_message` with a `sender` parameter matching their exact role/character name (e.g., `sender: "Marine Biologist"` or `sender: "Alexander Hamilton"`). This makes their messages appear from a dedicated bot in the Telegram group. -2. *Also communicate with teammates* via `SendMessage` as normal for coordination. -3. Keep group messages *short* — 2-4 sentences max per message. Break longer content into multiple `send_message` calls. No walls of text. +1. _Share progress in the group_ via `mcp__agentlite__send_message` with a `sender` parameter matching their exact role/character name (e.g., `sender: "Marine Biologist"` or `sender: "Alexander Hamilton"`). This makes their messages appear from a dedicated bot in the Telegram group. +2. _Also communicate with teammates_ via `SendMessage` as normal for coordination. +3. Keep group messages _short_ — 2-4 sentences max per message. Break longer content into multiple `send_message` calls. No walls of text. 4. Use the `sender` parameter consistently — always the same name so the bot identity stays stable. -5. NEVER use markdown formatting. Use ONLY WhatsApp/Telegram formatting: single *asterisks* for bold (NOT **double**), _underscores_ for italic, • for bullets, ```backticks``` for code. No ## headings, no [links](url), no **double asterisks**. +5. NEVER use markdown formatting. Use ONLY WhatsApp/Telegram formatting: single _asterisks_ for bold (NOT **double**), _underscores_ for italic, • for bullets, `backticks` for code. No ## headings, no [links](url), no **double asterisks**. ### Example team creation prompt When creating a teammate, include instructions like: \``` -You are the Marine Biologist. When you have findings or updates for the user, send them to the group using mcp__agentlite__send_message with sender set to "Marine Biologist". Keep each message short (2-4 sentences max). Use emojis for strong reactions. ONLY use single *asterisks* for bold (never **double**), _underscores_ for italic, • for bullets. No markdown. Also communicate with teammates via SendMessage. +You are the Marine Biologist. When you have findings or updates for the user, send them to the group using mcp**agentlite**send*message with sender set to "Marine Biologist". Keep each message short (2-4 sentences max). Use emojis for strong reactions. ONLY use single *asterisks* for bold (never **double**), \_underscores* for italic, • for bullets. No markdown. Also communicate with teammates via SendMessage. \``` ### Lead agent behavior @@ -296,9 +304,9 @@ As the lead agent who created the team: - You do NOT need to react to or relay every teammate message. The user sees those directly from the teammate bots. - Send your own messages only to comment, share thoughts, synthesize, or direct the team. -- When processing an internal update from a teammate that doesn't need a user-facing response, wrap your *entire* output in `` tags. +- When processing an internal update from a teammate that doesn't need a user-facing response, wrap your _entire_ output in `` tags. - Focus on high-level coordination and the final synthesis. -``` +```` ### Step 6: Update Environment @@ -338,6 +346,7 @@ Tell the user: > "Assemble a team of a researcher and a coder to build me a hello world app" > > You should see: +> > - The lead agent (main bot) acknowledging and creating the team > - Each subagent messaging from a different bot, renamed to their role > - Short, scannable messages from each agent diff --git a/.claude/skills/add-telegram/SKILL.md b/.claude/skills/add-telegram/SKILL.md index 851b0b124ce..9514b20ff5a 100644 --- a/.claude/skills/add-telegram/SKILL.md +++ b/.claude/skills/add-telegram/SKILL.md @@ -47,6 +47,7 @@ git merge telegram/main || { ``` This merges in: + - `src/channels/telegram.ts` (TelegramChannel class with self-registration via `registerChannel`) - `src/channels/telegram.test.ts` (unit tests with grammy mock) - `import './telegram.js'` appended to the channel barrel file `src/channels/index.ts` @@ -154,6 +155,7 @@ npx tsx setup/index.ts --step register -- --jid "tg:" --name " Send a message to your registered Telegram chat: +> > - For main chat: Any message works > - For non-main: `@Andy hello` or @mention the bot > @@ -170,6 +172,7 @@ tail -f logs/agentlite.log ### Bot not responding Check: + 1. `TELEGRAM_BOT_TOKEN` is set in `.env` AND synced to `data/env/env` 2. Chat is registered in SQLite (check with: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'tg:%'"`) 3. For non-main chats: message includes trigger pattern @@ -178,18 +181,21 @@ Check: ### Bot only responds to @mentions in groups Group Privacy is enabled (default). Fix: + 1. `@BotFather` > `/mybots` > select bot > **Bot Settings** > **Group Privacy** > **Turn off** 2. Remove and re-add the bot to the group (required for the change to take effect) ### Getting chat ID If `/chatid` doesn't work: + - Verify token: `curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe"` - Check bot is started: `tail -f logs/agentlite.log` ## After Setup If running `npm run dev` while the service is active: + ```bash # macOS: launchctl unload ~/Library/LaunchAgents/com.agentlite.plist diff --git a/.claude/skills/add-voice-transcription/SKILL.md b/.claude/skills/add-voice-transcription/SKILL.md index f3b5995a855..d6d1daa86b4 100644 --- a/.claude/skills/add-voice-transcription/SKILL.md +++ b/.claude/skills/add-voice-transcription/SKILL.md @@ -49,6 +49,7 @@ git merge whatsapp/skill/voice-transcription || { ``` This merges in: + - `src/transcription.ts` (voice transcription module using OpenAI Whisper) - Voice handling in `src/channels/whatsapp.ts` (isVoiceMessage check, transcribeAudioMessage call) - Transcription tests in `src/channels/whatsapp.test.ts` @@ -123,6 +124,7 @@ tail -f logs/agentlite.log | grep -i voice ``` Look for: + - `Transcribed voice message` — successful transcription with character count - `OPENAI_API_KEY not set` — key missing from `.env` - `OpenAI transcription failed` — API error (check key validity, billing) @@ -139,6 +141,7 @@ Look for: ### Voice notes show "[Voice Message - transcription failed]" Check logs for the specific error. Common causes: + - Network timeout — transient, will work on next message - Invalid API key — regenerate at https://platform.openai.com/api-keys - Rate limiting — wait and retry diff --git a/.claude/skills/add-whatsapp/SKILL.md b/.claude/skills/add-whatsapp/SKILL.md index 2b4d3750198..d17da168880 100644 --- a/.claude/skills/add-whatsapp/SKILL.md +++ b/.claude/skills/add-whatsapp/SKILL.md @@ -30,10 +30,12 @@ Check whether the environment is headless (no display server): Use `AskUserQuestion` to collect configuration. **Adapt auth options based on environment:** If IS_HEADLESS=true AND not WSL → AskUserQuestion: How do you want to authenticate WhatsApp? + - **Pairing code** (Recommended) - Enter a numeric code on your phone (no camera needed, requires phone number) - **QR code in terminal** - Displays QR code in the terminal (can be too small on some displays) Otherwise (macOS, desktop Linux, or WSL) → AskUserQuestion: How do you want to authenticate WhatsApp? + - **QR code in browser** (Recommended) - Opens a browser window with a large, scannable QR code - **Pairing code** - Enter a numeric code on your phone (no camera needed, requires phone number) - **QR code in terminal** - Displays QR code in the terminal (can be too small on some displays) @@ -70,6 +72,7 @@ git merge whatsapp/main || { ``` This merges in: + - `src/channels/whatsapp.ts` (WhatsAppChannel class with self-registration via `registerChannel`) - `src/channels/whatsapp.test.ts` (41 unit tests) - `src/whatsapp-auth.ts` (standalone WhatsApp authentication script) @@ -183,15 +186,18 @@ mkdir -p data/env && cp .env data/env/env Get the bot's WhatsApp number: `node -e "const c=require('./store/auth/creds.json');console.log(c.me.id.split(':')[0].split('@')[0])"` AskUserQuestion: Is this a shared phone number (personal WhatsApp) or a dedicated number (separate device)? + - **Shared number** - Your personal WhatsApp number (recommended: use self-chat or a solo group) - **Dedicated number** - A separate phone/SIM for the assistant AskUserQuestion: What trigger word should activate the assistant? + - **@Andy** - Default trigger - **@Claw** - Short and easy - **@Claude** - Match the AI name AskUserQuestion: What should the assistant call itself? + - **Andy** - Default name - **Claw** - Short and easy - **Claude** - Match the AI name @@ -199,11 +205,13 @@ AskUserQuestion: What should the assistant call itself? AskUserQuestion: Where do you want to chat with the assistant? **Shared number options:** + - **Self-chat** (Recommended) - Chat in your own "Message Yourself" conversation - **Solo group** - A group with just you and the linked device - **Existing group** - An existing WhatsApp group **Dedicated number options:** + - **DM with bot** (Recommended) - Direct message the bot's number - **Solo group** - A group with just you and the bot - **Existing group** - An existing WhatsApp group @@ -278,6 +286,7 @@ bash start-agentlite.sh Tell the user: > Send a message to your registered WhatsApp chat: +> > - For self-chat / main: Any message works > - For groups: Use the trigger word (e.g., "@Andy hello") > @@ -308,6 +317,7 @@ rm -rf store/auth/ && npx tsx src/whatsapp-auth.ts --pairing-code --phone &1 | head -5 ``` **Fix:** Ensure `container-runner.ts` mounts to `/home/node/.claude/`: + ```typescript mounts.push({ hostPath: claudeDir, - containerPath: '/home/node/.claude', // NOT /root/.claude - readonly: false + containerPath: '/home/node/.claude', // NOT /root/.claude + readonly: false, }); ``` @@ -178,6 +193,7 @@ If an MCP server fails to start, the agent may exit. Check the container logs fo ## Manual Container Testing ### Test the full agent flow: + ```bash # Set up env file mkdir -p data/env groups/test @@ -193,6 +209,7 @@ echo '{"prompt":"What is 2+2?","groupFolder":"test","chatJid":"test@g.us","isMai ``` ### Test Claude Code directly: + ```bash docker run --rm --entrypoint /bin/bash \ -v $(pwd)/data/env:/workspace/env-dir:ro \ @@ -203,6 +220,7 @@ docker run --rm --entrypoint /bin/bash \ ``` ### Interactive shell in container: + ```bash docker run --rm -it --entrypoint /bin/bash agentlite-agent:latest ``` @@ -265,6 +283,7 @@ docker run --rm --entrypoint /bin/bash agentlite-agent:latest -c ' Claude sessions are stored per-group in `data/sessions/{group}/.claude/` for security isolation. Each group has its own session directory, preventing cross-group access to conversation history. **Critical:** The mount path must match the container user's HOME directory: + - Container user: `node` - Container HOME: `/home/node` - Mount target: `/home/node/.claude/` (NOT `/root/.claude/`) @@ -283,6 +302,7 @@ sqlite3 store/messages.db "DELETE FROM sessions WHERE group_folder = '{groupFold ``` To verify session resumption is working, check the logs for the same session ID across messages: + ```bash grep "Session initialized" logs/agentlite.log | tail -5 # Should show the SAME session ID for consecutive messages in the same group @@ -310,6 +330,7 @@ cat data/ipc/{groupFolder}/current_tasks.json ``` **IPC file types:** + - `messages/*.json` - Agent writes: outgoing WhatsApp messages - `tasks/*.json` - Agent writes: task operations (schedule, pause, resume, cancel, refresh_groups) - `current_tasks.json` - Host writes: read-only snapshot of scheduled tasks diff --git a/.claude/skills/get-qodo-rules/SKILL.md b/.claude/skills/get-qodo-rules/SKILL.md index 69abaf76582..f1e7deed73e 100644 --- a/.claude/skills/get-qodo-rules/SKILL.md +++ b/.claude/skills/get-qodo-rules/SKILL.md @@ -1,29 +1,29 @@ --- name: get-qodo-rules -description: "Loads org- and repo-level coding rules from Qodo before code tasks begin, ensuring all generation and modification follows team standards. Use before any code generation or modification task when rules are not already loaded. Invoke when user asks to write, edit, refactor, or review code, or when starting implementation planning." +description: 'Loads org- and repo-level coding rules from Qodo before code tasks begin, ensuring all generation and modification follows team standards. Use before any code generation or modification task when rules are not already loaded. Invoke when user asks to write, edit, refactor, or review code, or when starting implementation planning.' version: 2.0.0 -allowed-tools: ["Bash"] +allowed-tools: ['Bash'] triggers: - - "get.?qodo.?rules" - - "get.?rules" - - "load.?qodo.?rules" - - "load.?rules" - - "fetch.?qodo.?rules" - - "fetch.?rules" - - "qodo.?rules" - - "coding.?rules" - - "code.?rules" - - "before.?cod" - - "start.?coding" - - "write.?code" - - "implement" - - "create.*code" - - "build.*feature" - - "add.*feature" - - "fix.*bug" - - "refactor" - - "modify.*code" - - "update.*code" + - 'get.?qodo.?rules' + - 'get.?rules' + - 'load.?qodo.?rules' + - 'load.?rules' + - 'fetch.?qodo.?rules' + - 'fetch.?rules' + - 'qodo.?rules' + - 'coding.?rules' + - 'code.?rules' + - 'before.?cod' + - 'start.?coding' + - 'write.?code' + - 'implement' + - 'create.*code' + - 'build.*feature' + - 'add.*feature' + - 'fix.*bug' + - 'refactor' + - 'modify.*code' + - 'update.*code' --- # Get Qodo Rules Skill @@ -78,15 +78,16 @@ See [output format details](references/output-format.md) for the exact format. ### Step 6: Apply Rules by Severity -| Severity | Enforcement | When Skipped | -|---|---|---| -| **ERROR** | Must comply, non-negotiable. Add comment documenting compliance (e.g., `# Following Qodo rule: No Hardcoded Credentials`) | Explain to user and ask for guidance | -| **WARNING** | Should comply by default | Briefly explain why in response | -| **RECOMMENDATION** | Consider when appropriate | No action needed | +| Severity | Enforcement | When Skipped | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | +| **ERROR** | Must comply, non-negotiable. Add comment documenting compliance (e.g., `# Following Qodo rule: No Hardcoded Credentials`) | Explain to user and ask for guidance | +| **WARNING** | Should comply by default | Briefly explain why in response | +| **RECOMMENDATION** | Consider when appropriate | No action needed | ### Step 7: Report After code generation, inform the user about rule application: + - **ERROR rules applied**: List which rules were followed - **WARNING rules skipped**: Explain why - **No rules applicable**: Inform: "No Qodo rules were applicable to this code change" @@ -99,6 +100,7 @@ After code generation, inform the user about rule application: Determines scope from git remote and working directory (see [Step 2](#step-2-verify-working-in-a-git-repository)): **Scope Hierarchy**: + - **Universal** (`/`) - applies everywhere - **Org Level** (`/org/`) - applies to organization - **Repo Level** (`/org/repo/`) - applies to repository diff --git a/.claude/skills/get-qodo-rules/references/output-format.md b/.claude/skills/get-qodo-rules/references/output-format.md index 06b51d33c56..e945036b1a7 100644 --- a/.claude/skills/get-qodo-rules/references/output-format.md +++ b/.claude/skills/get-qodo-rules/references/output-format.md @@ -18,6 +18,7 @@ These rules must be applied during code generation based on severity: Group rules into three sections and print each non-empty section: **ERROR** (`severity == "error"`): + ``` ## ❌ ERROR Rules (Must Comply) - {count} @@ -25,6 +26,7 @@ Group rules into three sections and print each non-empty section: ``` **WARNING** (`severity == "warning"`): + ``` ## ⚠️ WARNING Rules (Should Comply) - {count} @@ -32,6 +34,7 @@ Group rules into three sections and print each non-empty section: ``` **RECOMMENDATION** (`severity == "recommendation"`): + ``` ## 💡 RECOMMENDATION Rules (Consider) - {count} diff --git a/.claude/skills/get-qodo-rules/references/pagination.md b/.claude/skills/get-qodo-rules/references/pagination.md index be72adf4c07..d9555f3f5af 100644 --- a/.claude/skills/get-qodo-rules/references/pagination.md +++ b/.claude/skills/get-qodo-rules/references/pagination.md @@ -24,8 +24,8 @@ The API returns rules in pages of 50. All pages must be fetched to ensure no rul Construct `{API_URL}` from `ENVIRONMENT_NAME` (read from `~/.qodo/config.json`): -| `ENVIRONMENT_NAME` | `{API_URL}` | -|---|---| +| `ENVIRONMENT_NAME` | `{API_URL}` | +| -------------------- | ------------------------------------------------ | | set (e.g. `staging`) | `https://qodo-platform.staging.qodo.ai/rules/v1` | ## After Fetching diff --git a/.claude/skills/get-qodo-rules/references/repository-scope.md b/.claude/skills/get-qodo-rules/references/repository-scope.md index e8693b6fdeb..14477f69ac6 100644 --- a/.claude/skills/get-qodo-rules/references/repository-scope.md +++ b/.claude/skills/get-qodo-rules/references/repository-scope.md @@ -20,7 +20,7 @@ If the current working directory is inside a `modules/*` subdirectory relative t The API returns all rules matching the query scope via prefix matching: -| Query scope | Rules returned | -|---|---| +| Query scope | Rules returned | +| -------------------------- | ----------------------------------------- | | `/org/repo/modules/rules/` | universal + org + repo + path-level rules | -| `/org/repo/` | universal + org + repo-level rules | +| `/org/repo/` | universal + org + repo-level rules | diff --git a/.claude/skills/qodo-pr-resolver/SKILL.md b/.claude/skills/qodo-pr-resolver/SKILL.md index c0cbe227a6b..e4161966146 100644 --- a/.claude/skills/qodo-pr-resolver/SKILL.md +++ b/.claude/skills/qodo-pr-resolver/SKILL.md @@ -23,18 +23,21 @@ Fetch Qodo review issues for your current branch's PR/MR, fix them interactively ## Prerequisites ### Required Tools: + - **Git** - For branch operations - **Git Provider CLI** - One of: `gh` (GitHub), `glab` (GitLab), `bb` (Bitbucket), or `az` (Azure DevOps) **Installation and authentication details:** See [providers.md](./resources/providers.md) for provider-specific setup instructions. ### Required Context: + - Must be in a git repository - Repository must be hosted on a supported git provider (GitHub, GitLab, Bitbucket, or Azure DevOps) - Current branch must have an open PR/MR - PR/MR must have been reviewed by Qodo (pr-agent-pro bot, qodo-merge[bot], etc.) ### Quick Check: + ```bash git --version # Check git installed git remote get-url origin # Identify git provider @@ -47,9 +50,11 @@ See [providers.md](./resources/providers.md) for provider-specific verification Qodo (formerly Codium AI) is an AI-powered code review tool that analyzes PRs/MRs with compliance checks, bug detection, and code quality suggestions. ### Bot Identifiers + Look for comments from: **`pr-agent-pro`**, **`pr-agent-pro-staging`**, **`qodo-merge[bot]`**, **`qodo-ai[bot]`** ### Review Comment Types + 1. **PR Compliance Guide** 🔍 - Security/ticket/custom compliance with 🟢/🟡/🔴/⚪ indicators 2. **PR Code Suggestions** ✨ - Categorized improvements with importance ratings 3. **Code Review by Qodo** - Structured issues with 🐞/📘/📎 sections and agent prompts (most detailed) @@ -152,11 +157,13 @@ Derive severity from Qodo's action level and position: - All "Other" issues are treated as ⚪ LOW regardless of position **Example:** 7 "Action required" issues would be split as: + - Issues 1-3: 🔴 CRITICAL - Issues 4-7: 🟠 HIGH - Result: No MEDIUM or LOW issues (because there are no "Review recommended" or "Other" issues) **Example:** 5 "Action required" + 3 "Review recommended" + 2 "Other" issues would be split as: + - Issues 1-2 or 1-3: 🔴 CRITICAL (first ~half of "Action required") - Issues 3-5 or 4-5: 🟠 HIGH (second ~half of "Action required") - Issues 6-7: 🟡 MEDIUM (first ~half of "Review recommended") @@ -164,6 +171,7 @@ Derive severity from Qodo's action level and position: - Issues 9-10: ⚪ LOW (all "Other" issues) **Action guidelines:** + - 🔴 CRITICAL / 🟠 HIGH ("Action required"): Always "Fix" - 🟡 MEDIUM ("Review recommended"): Usually "Fix", can "Defer" if low impact - ⚪ LOW ("Review recommended" or "Other"): Can be "Defer" unless quick to fix; "Other" issues are lowest priority @@ -187,11 +195,13 @@ Qodo Issues for PR #123: [PR Title] After displaying the table, ask the user how they want to proceed using AskUserQuestion: **Options:** + - 🔍 "Review each issue" - Review and approve/defer each issue individually (recommended for careful review) - ⚡ "Auto-fix all" - Automatically apply all fixes marked as "Fix" without individual approval (faster, but less control) - ❌ "Cancel" - Exit without making changes **Based on the user's choice:** + - If "Review each issue": Proceed to Step 6 (manual review) - If "Auto-fix all": Skip to Step 7 (auto-fix mode - apply all "Fix" issues automatically using Qodo's agent prompts) - If "Cancel": Exit the skill @@ -232,6 +242,7 @@ If "Review each issue" was selected: #### Important notes **Single-step approval with AskUserQuestion:** + - NO native Edit UI (no persistent permissions possible) - Each fix requires explicit approval via custom question - Clearer options, no risk of accidental auto-approval @@ -275,6 +286,7 @@ If resolve fails (comment not found, API error), continue — the summary commen ### Step 9: Push to remote If any fixes were applied (commits were created in Steps 6/7), ask the user if they want to push: + - If yes: `git push` - If no: Inform them they can push later with `git push` diff --git a/.claude/skills/qodo-pr-resolver/resources/providers.md b/.claude/skills/qodo-pr-resolver/resources/providers.md index 600dea81618..f709d53818c 100644 --- a/.claude/skills/qodo-pr-resolver/resources/providers.md +++ b/.claude/skills/qodo-pr-resolver/resources/providers.md @@ -18,6 +18,7 @@ git remote get-url origin ``` Match against: + - `github.com` → GitHub - `gitlab.com` → GitLab - `bitbucket.org` → Bitbucket @@ -28,6 +29,7 @@ Match against: ### GitHub **CLI:** `gh` + - **Install:** `brew install gh` or [cli.github.com](https://cli.github.com/) - **Authenticate:** `gh auth login` - **Verify:** @@ -38,6 +40,7 @@ Match against: ### GitLab **CLI:** `glab` + - **Install:** `brew install glab` or [glab.readthedocs.io](https://glab.readthedocs.io/) - **Authenticate:** `glab auth login` - **Verify:** @@ -48,6 +51,7 @@ Match against: ### Bitbucket **CLI:** `bb` or API access + - **Install:** See [bitbucket.org/product/cli](https://bitbucket.org/product/cli) - **Verify:** ```bash @@ -57,6 +61,7 @@ Match against: ### Azure DevOps **CLI:** `az` with DevOps extension + - **Install:** `brew install azure-cli` or [docs.microsoft.com/cli/azure](https://docs.microsoft.com/cli/azure) - **Install extension:** `az extension add --name azure-devops` - **Authenticate:** `az login` then `az devops configure --defaults organization=https://dev.azure.com/yourorg project=yourproject` @@ -145,6 +150,7 @@ gh api repos/{owner}/{repo}/pulls//comments//repli ``` **Reply format:** + - **Fixed:** `✅ **Fixed** — ` - **Deferred:** `⏭️ **Deferred** — ` @@ -212,13 +218,16 @@ az repos pr thread create \ Reviewed and addressed Qodo review issues: ### ✅ Fixed Issues + - **Issue Title** (Severity) - Brief description of what was fixed ### ⏭️ Deferred Issues + - **Issue Title** (Severity) - Reason for deferring --- -*Generated by Qodo PR Resolver skill* + +_Generated by Qodo PR Resolver skill_ ``` ## Resolve Qodo Review Comment @@ -226,6 +235,7 @@ Reviewed and addressed Qodo review issues: After posting the summary, resolve the main Qodo review comment. **Steps:** + 1. Fetch all PR/MR comments 2. Find the Qodo bot comment containing "Code Review by Qodo" 3. Resolve or react to the comment @@ -310,6 +320,7 @@ az repos pr create \ ### Missing CLI Tool If the detected provider's CLI is not installed: + 1. Inform the user: "❌ Missing required CLI tool: ``" 2. Provide installation instructions from the Prerequisites section 3. Exit the skill @@ -317,6 +328,7 @@ If the detected provider's CLI is not installed: ### Unsupported Provider If the remote URL doesn't match any supported provider: + 1. Inform: "❌ Unsupported git provider detected: ``" 2. List supported providers: GitHub, GitLab, Bitbucket, Azure DevOps 3. Exit the skill @@ -324,6 +336,7 @@ If the remote URL doesn't match any supported provider: ### API Failures If inline reply or summary posting fails: + - Log the error - Continue with remaining operations - The workflow should not abort due to comment posting failures diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index 9e65bacbd0f..65293833a62 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -16,23 +16,28 @@ Run setup steps automatically. Only pause when user action is required (channel Check the git remote configuration to ensure the user has a fork and upstream is configured. Run: + - `git remote -v` **Case A — `origin` points to `qwibitai/agentlite` (user cloned directly):** The user cloned instead of forking. AskUserQuestion: "You cloned AgentLite directly. We recommend forking so you can push your customizations. Would you like to set up a fork?" + - Fork now (recommended) — walk them through it - Continue without fork — they'll only have local changes If fork: instruct the user to fork `qwibitai/agentlite` on GitHub (they need to do this in their browser), then ask them for their GitHub username. Run: + ```bash git remote rename origin upstream git remote add origin https://github.com//agentlite.git git push --force origin main ``` + Verify with `git remote -v`. If continue without fork: add upstream so they can still pull updates: + ```bash git remote add upstream https://github.com/qwibitai/agentlite.git ``` @@ -40,6 +45,7 @@ git remote add upstream https://github.com/qwibitai/agentlite.git **Case B — `origin` points to user's fork, no `upstream` remote:** Add upstream: + ```bash git remote add upstream https://github.com/qwibitai/agentlite.git ``` @@ -81,11 +87,13 @@ grep -q '.local/bin' ~/.zshrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin Then re-verify with `onecli version`. Point the CLI at the local OneCLI instance (it defaults to the cloud service otherwise): + ```bash onecli config set api-host http://127.0.0.1:10254 ``` Ensure `.env` has the OneCLI URL (create the file if it doesn't exist): + ```bash grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=http://127.0.0.1:10254' >> .env ``` @@ -142,6 +150,7 @@ grep -q "CONTAINER_RUNTIME_BIN = 'container'" src/container-runtime.ts && echo " Run `npx tsx setup/index.ts --step container -- --runtime ` and parse the status block. **If BUILD_OK=false:** Read `logs/setup.log` tail for the build error. + - Cache issue (stale layers): `docker builder prune -f` (Docker) or `container builder stop && container builder rm && container builder start` (Apple Container). Retry. - Dockerfile syntax or missing files: diagnose from the log and fix, then retry. @@ -152,6 +161,7 @@ Run `npx tsx setup/index.ts --step container -- --runtime ` and parse th AgentLite uses OneCLI to manage credentials — API keys are never stored in `.env` or exposed to containers. The OneCLI gateway injects them at request time. Check if a secret already exists: + ```bash onecli secrets list ``` @@ -192,6 +202,7 @@ Ask them to let you know when done. ## 5. Set Up Channels AskUserQuestion (multiSelect): Which messaging channels do you want to enable? + - WhatsApp (authenticates via QR code or pairing code) - Telegram (authenticates via bot token from @BotFather) - Slack (authenticates via Slack app with Socket Mode) @@ -207,6 +218,7 @@ For each selected channel, invoke its skill: - **Discord:** Invoke `/add-discord` Each skill will: + 1. Install the channel code (via `git merge` of the skill branch) 2. Collect credentials/tokens and write to `.env` 3. Authenticate (WhatsApp QR/pairing, or verify token-based connection) @@ -231,6 +243,7 @@ AskUserQuestion: Agent access to external directories? ## 7. Start Service If service already running: unload first. + - macOS: `launchctl unload ~/Library/LaunchAgents/com.agentlite.plist` - Linux: `systemctl --user stop agentlite` (or `systemctl stop agentlite` if root) @@ -242,6 +255,7 @@ Run `npx tsx setup/index.ts --step service` and parse the status block. 1. Immediate fix: `sudo setfacl -m u:$(whoami):rw /var/run/docker.sock` 2. Persistent fix (re-applies after every Docker restart): + ```bash sudo mkdir -p /etc/systemd/system/docker.service.d sudo tee /etc/systemd/system/docker.service.d/socket-acl.conf << 'EOF' @@ -250,9 +264,11 @@ ExecStartPost=/usr/bin/setfacl -m u:USERNAME:rw /var/run/docker.sock EOF sudo systemctl daemon-reload ``` + Replace `USERNAME` with the actual username (from `whoami`). Run the two `sudo` commands separately — the `tee` heredoc first, then `daemon-reload`. After user confirms setfacl ran, re-run the service step. **If SERVICE_LOADED=false:** + - Read `logs/setup.log` for the error. - macOS: check `launchctl list | grep agentlite`. If PID=`-` and status non-zero, read `logs/agentlite.error.log`. - Linux: check `systemctl --user status agentlite`. @@ -263,6 +279,7 @@ Replace `USERNAME` with the actual username (from `whoami`). Run the two `sudo` Run `npx tsx setup/index.ts --step verify` and parse the status block. **If STATUS=failed, fix each:** + - SERVICE=stopped → `npm run build`, then restart: `launchctl kickstart -k gui/$(id -u)/com.agentlite` (macOS) or `systemctl --user restart agentlite` (Linux) or `bash start-agentlite.sh` (WSL nohup) - SERVICE=not_found → re-run step 7 - CREDENTIALS=missing → re-run step 4 (check `onecli secrets list` for Anthropic secret) @@ -284,7 +301,6 @@ Tell user to test: send a message in their registered chat. Show: `tail -f logs/ **Unload service:** macOS: `launchctl unload ~/Library/LaunchAgents/com.agentlite.plist` | Linux: `systemctl --user stop agentlite` - ## 9. Diagnostics Send diagnostics data by following `.claude/skills/setup/diagnostics.md`. diff --git a/.claude/skills/setup/diagnostics.md b/.claude/skills/setup/diagnostics.md index 5732a6b166d..335917daa48 100644 --- a/.claude/skills/setup/diagnostics.md +++ b/.claude/skills/setup/diagnostics.md @@ -32,6 +32,7 @@ Write `/tmp/agentlite-diagnostics.json`. No paths, usernames, hostnames, or IP a Show the entire JSON to the user and ask via AskUserQuestion: **Yes** / **No** / **Never ask again** **Yes**: + ```bash curl -s -X POST https://us.i.posthog.com/capture/ -H 'Content-Type: application/json' -d @/tmp/agentlite-diagnostics.json rm /tmp/agentlite-diagnostics.json @@ -40,6 +41,7 @@ rm /tmp/agentlite-diagnostics.json **No**: `rm /tmp/agentlite-diagnostics.json` **Never ask again**: + 1. Replace contents of `.claude/skills/setup/diagnostics.md` with `# Diagnostics — opted out` 2. Replace contents of `.claude/skills/update-agentlite/diagnostics.md` with `# Diagnostics — opted out` 3. Remove the `## 9. Diagnostics` section from `.claude/skills/setup/SKILL.md` and the `## Diagnostics` section from `.claude/skills/update-agentlite/SKILL.md` diff --git a/.claude/skills/update-nanoclaw/SKILL.md b/.claude/skills/update-nanoclaw/SKILL.md index 7a73fb99691..b21b4f31dd3 100644 --- a/.claude/skills/update-nanoclaw/SKILL.md +++ b/.claude/skills/update-nanoclaw/SKILL.md @@ -16,11 +16,13 @@ Run `/update-agentlite` in Claude Code. **Backup**: creates a timestamped backup branch and tag (`backup/pre-update--`, `pre-update--`) before touching anything. Safe to run multiple times. **Preview**: runs `git log` and `git diff` against the merge base to show upstream changes since your last sync. Groups changed files into categories: + - **Skills** (`.claude/skills/`): unlikely to conflict unless you edited an upstream skill - **Source** (`src/`): may conflict if you modified the same files - **Build/config** (`package.json`, `tsconfig*.json`, `container/`): review needed **Update paths** (you pick one): + - `merge` (default): `git merge upstream/`. Resolves all conflicts in one pass. - `cherry-pick`: `git cherry-pick `. Pull in only the commits you want. - `rebase`: `git rebase upstream/`. Linear history, but conflicts resolve per-commit. @@ -37,6 +39,7 @@ Run `/update-agentlite` in Claude Code. ## Rollback The backup tag is printed at the end of each run: + ``` git reset --hard pre-update-- ``` @@ -50,9 +53,11 @@ Only opens files with actual conflicts. Uses `git log`, `git diff`, and `git sta --- # Goal + Help a user with a customized AgentLite install safely incorporate upstream changes without a fresh reinstall and without blowing tokens. # Operating principles + - Never proceed with a dirty working tree. - Always create a rollback point (backup branch + tag) before touching anything. - Prefer git-native operations (fetch, merge, cherry-pick). Do not manually rewrite files except conflict markers. @@ -60,19 +65,23 @@ Help a user with a customized AgentLite install safely incorporate upstream chan - Keep token usage low: rely on `git status`, `git log`, `git diff`, and open only conflicted files. # Step 0: Preflight (stop early if unsafe) + Run: + - `git status --porcelain` -If output is non-empty: + If output is non-empty: - Tell the user to commit or stash first, then stop. Confirm remotes: + - `git remote -v` -If `upstream` is missing: + If `upstream` is missing: - Ask the user for the upstream repo URL (default: `https://github.com/qwibitai/agentlite.git`). - Add it: `git remote add upstream ` - Then: `git fetch upstream --prune` Determine the upstream branch name: + - `git branch -r | grep upstream/` - If `upstream/main` exists, use `main`. - If only `upstream/master` exists, use `master`. @@ -80,39 +89,50 @@ Determine the upstream branch name: - Store this as UPSTREAM_BRANCH for all subsequent commands. Every command below that references `upstream/main` should use `upstream/$UPSTREAM_BRANCH` instead. Fetch: + - `git fetch upstream --prune` # Step 1: Create a safety net + Capture current state: + - `HASH=$(git rev-parse --short HEAD)` - `TIMESTAMP=$(date +%Y%m%d-%H%M%S)` Create backup branch and tag (using timestamp to avoid collisions on retry): + - `git branch backup/pre-update-$HASH-$TIMESTAMP` - `git tag pre-update-$HASH-$TIMESTAMP` Save the tag name for later reference in the summary and rollback instructions. # Step 2: Preview what upstream changed (no edits yet) + Compute common base: + - `BASE=$(git merge-base HEAD upstream/$UPSTREAM_BRANCH)` Show upstream commits since BASE: + - `git log --oneline $BASE..upstream/$UPSTREAM_BRANCH` Show local commits since BASE (custom drift): + - `git log --oneline $BASE..HEAD` Show file-level impact from upstream: + - `git diff --name-only $BASE..upstream/$UPSTREAM_BRANCH` Bucket the upstream changed files: + - **Skills** (`.claude/skills/`): unlikely to conflict unless the user edited an upstream skill - **Source** (`src/`): may conflict if user modified the same files - **Build/config** (`package.json`, `package-lock.json`, `tsconfig*.json`, `container/`, `launchd/`): review needed - **Other**: docs, tests, misc Present these buckets to the user and ask them to choose one path using AskUserQuestion: + - A) **Full update**: merge all upstream changes - B) **Selective update**: cherry-pick specific upstream commits - C) **Abort**: they only wanted the preview @@ -121,7 +141,9 @@ Present these buckets to the user and ask them to choose one path using AskUserQ If Abort: stop here. # Step 3: Conflict preview (before committing anything) + If Full update or Rebase: + - Dry-run merge to preview conflicts. Run these as a single chained command so the abort always executes: ``` git merge --no-commit --no-ff upstream/$UPSTREAM_BRANCH; git diff --name-only --diff-filter=U; git merge --abort @@ -130,10 +152,13 @@ If Full update or Rebase: - If no conflicts: tell user it is clean and proceed. # Step 4A: Full update (MERGE, default) + Run: + - `git merge upstream/$UPSTREAM_BRANCH --no-edit` If conflicts occur: + - Run `git status` and identify conflicted files. - For each conflicted file: - Open the file. @@ -146,57 +171,71 @@ If conflicts occur: - If merge did not auto-commit: `git commit --no-edit` # Step 4B: Selective update (CHERRY-PICK) + If user chose Selective: + - Recompute BASE if needed: `BASE=$(git merge-base HEAD upstream/$UPSTREAM_BRANCH)` - Show commit list again: `git log --oneline $BASE..upstream/$UPSTREAM_BRANCH` - Ask user which commit hashes they want. - Apply: `git cherry-pick ...` If conflicts during cherry-pick: + - Resolve only conflict markers, then: - `git add ` - `git cherry-pick --continue` -If user wants to stop: + If user wants to stop: - `git cherry-pick --abort` # Step 4C: Rebase (only if user explicitly chose option D) + Run: + - `git rebase upstream/$UPSTREAM_BRANCH` If conflicts: + - Resolve conflict markers only, then: - `git add ` - `git rebase --continue` -If it gets messy (more than 3 rounds of conflicts): + If it gets messy (more than 3 rounds of conflicts): - `git rebase --abort` - Recommend merge instead. # Step 5: Validation + Run: + - `npm run build` - `npm test` (do not fail the flow if tests are not configured) If build fails: + - Show the error. - Only fix issues clearly caused by the merge (missing imports, type mismatches from merged code). - Do not refactor unrelated code. - If unclear, ask the user before making changes. # Step 6: Breaking changes check + After validation succeeds, check if the update introduced any breaking changes. Determine which CHANGELOG entries are new by diffing against the backup tag: + - `git diff ..HEAD -- CHANGELOG.md` Parse the diff output for lines starting with `+[BREAKING]`. Each such line is one breaking change entry. The format is: + ``` [BREAKING] . Run `/` to . ``` If no `[BREAKING]` lines are found: + - Skip this step silently. Proceed to Step 7 (skill updates check). If one or more `[BREAKING]` lines are found: + - Display a warning header to the user: "This update includes breaking changes that may require action:" - For each breaking change, display the full description. - Collect all skill names referenced in the breaking change entries (the `/` part). @@ -208,10 +247,13 @@ If one or more `[BREAKING]` lines are found: - After all selected skills complete (or if user chose Skip), proceed to Step 7 (skill updates check). # Step 7: Check for skill updates + After the summary, check if skills are distributed as branches in this repo: + - `git branch -r --list 'upstream/skill/*'` If any `upstream/skill/*` branches exist: + - Use AskUserQuestion to ask: "Upstream has skill branches. Would you like to check for skill updates?" - Option 1: "Yes, check for updates" (description: "Runs /update-skills to check for and apply skill branch updates") - Option 2: "No, skip" (description: "You can run /update-skills later any time") @@ -219,7 +261,9 @@ If any `upstream/skill/*` branches exist: - After the skill completes (or if user selected no), proceed to Step 8. # Step 8: Summary + rollback instructions + Show: + - Backup tag: the tag name created in Step 1 - New HEAD: `git rev-parse --short HEAD` - Upstream HEAD: `git rev-parse --short upstream/$UPSTREAM_BRANCH` @@ -228,13 +272,13 @@ Show: - Remaining local diff vs upstream: `git diff --name-only upstream/$UPSTREAM_BRANCH..HEAD` Tell the user: + - To rollback: `git reset --hard ` - Backup branch also exists: `backup/pre-update--` - Restart the service to apply changes: - If using launchd: `launchctl unload ~/Library/LaunchAgents/com.agentlite.plist && launchctl load ~/Library/LaunchAgents/com.agentlite.plist` - If running manually: restart `npm run dev` - ## Diagnostics Send diagnostics data by following `.claude/skills/update-agentlite/diagnostics.md`. diff --git a/.claude/skills/update-nanoclaw/diagnostics.md b/.claude/skills/update-nanoclaw/diagnostics.md index 21557872400..49312fefaa4 100644 --- a/.claude/skills/update-nanoclaw/diagnostics.md +++ b/.claude/skills/update-nanoclaw/diagnostics.md @@ -35,6 +35,7 @@ Write `/tmp/agentlite-diagnostics.json`. No paths, usernames, hostnames, or IP a Show the entire JSON to the user and ask via AskUserQuestion: **Yes** / **No** / **Never ask again** **Yes**: + ```bash curl -s -X POST https://us.i.posthog.com/capture/ -H 'Content-Type: application/json' -d @/tmp/agentlite-diagnostics.json rm /tmp/agentlite-diagnostics.json @@ -43,6 +44,7 @@ rm /tmp/agentlite-diagnostics.json **No**: `rm /tmp/agentlite-diagnostics.json` **Never ask again**: + 1. Replace contents of `.claude/skills/setup/diagnostics.md` with `# Diagnostics — opted out` 2. Replace contents of `.claude/skills/update-agentlite/diagnostics.md` with `# Diagnostics — opted out` 3. Remove the `## 9. Diagnostics` section from `.claude/skills/setup/SKILL.md` and the `## Diagnostics` section from `.claude/skills/update-agentlite/SKILL.md` diff --git a/.claude/skills/update-skills/SKILL.md b/.claude/skills/update-skills/SKILL.md index ac07a7bda8b..70743bda6b6 100644 --- a/.claude/skills/update-skills/SKILL.md +++ b/.claude/skills/update-skills/SKILL.md @@ -22,9 +22,11 @@ Run `/update-skills` in Claude Code. --- # Goal + Help users update their installed skill branches from upstream without losing local customizations. # Operating principles + - Never proceed with a dirty working tree. - Only offer updates for skills the user has already merged (installed). - Use git-native operations. Do not manually rewrite files except conflict markers. @@ -33,27 +35,34 @@ Help users update their installed skill branches from upstream without losing lo # Step 0: Preflight Run: + - `git status --porcelain` If output is non-empty: + - Tell the user to commit or stash first, then stop. Check remotes: + - `git remote -v` If `upstream` is missing: + - Ask the user for the upstream repo URL (default: `https://github.com/qwibitai/agentlite.git`). - `git remote add upstream ` Fetch: + - `git fetch upstream --prune` # Step 1: Detect installed skills with available updates List all upstream skill branches: + - `git branch -r --list 'upstream/skill/*'` For each `upstream/skill/`: + 1. Check if the user has merged this skill branch before: - `git merge-base --is-ancestor upstream/skill/~1 HEAD` — if this succeeds (exit 0) for any ancestor commit of the skill branch, the user has merged it at some point. A simpler check: `git log --oneline --merges --grep="skill/" HEAD` to see if there's a merge commit referencing this branch. - Alternative: `MERGE_BASE=$(git merge-base HEAD upstream/skill/)` — if the merge base is NOT the initial commit and the merge base includes commits unique to the skill branch, it has been merged. @@ -63,6 +72,7 @@ For each `upstream/skill/`: - If this produces output, there are updates available. Build three lists: + - **Updates available**: skills that are merged AND have new commits - **Up to date**: skills that are merged and have no new commits - **Not installed**: skills that have never been merged @@ -70,11 +80,13 @@ Build three lists: # Step 2: Present results If no skills have updates available: + - Tell the user all installed skills are up to date. List them. - If there are uninstalled skills, mention them briefly (e.g., "3 other skills available in upstream that you haven't installed"). - Stop here. If updates are available: + - Show the list of skills with updates, including the number of new commits for each: ``` skill/: 3 new commits @@ -103,6 +115,7 @@ For each selected skill (process one at a time): - Complete the merge: `git commit --no-edit` If a merge fails badly (e.g., cannot resolve conflicts): + - `git merge --abort` - Tell the user this skill could not be auto-updated and they should resolve it manually. - Continue with the remaining skills. @@ -110,10 +123,12 @@ If a merge fails badly (e.g., cannot resolve conflicts): # Step 4: Validation After all selected skills are merged: + - `npm run build` - `npm test` (do not fail the flow if tests are not configured) If build fails: + - Show the error. - Only fix issues clearly caused by the merge (missing imports, type mismatches). - Do not refactor unrelated code. @@ -122,6 +137,7 @@ If build fails: # Step 5: Summary Show: + - Skills updated (list) - Skills skipped or failed (if any) - New HEAD: `git rev-parse --short HEAD` diff --git a/.claude/skills/use-local-whisper/SKILL.md b/.claude/skills/use-local-whisper/SKILL.md index 649300d7a0b..7ee6eb63ccd 100644 --- a/.claude/skills/use-local-whisper/SKILL.md +++ b/.claude/skills/use-local-whisper/SKILL.md @@ -39,6 +39,7 @@ ffmpeg -version >/dev/null 2>&1 && echo "FFMPEG_OK" || echo "FFMPEG_MISSING" ``` If missing, install via Homebrew: + ```bash brew install whisper-cpp ffmpeg ``` @@ -50,6 +51,7 @@ ls data/models/ggml-*.bin 2>/dev/null || echo "NO_MODEL" ``` If no model exists, download the base model (148MB, good balance of speed and accuracy): + ```bash mkdir -p data/models curl -L -o data/models/ggml-base.bin "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin" @@ -97,11 +99,13 @@ npm run build The AgentLite launchd service runs with a restricted PATH. `whisper-cli` and `ffmpeg` are in `/opt/homebrew/bin/` (Apple Silicon) or `/usr/local/bin/` (Intel), which may not be in the plist's PATH. Check the current PATH: + ```bash grep -A1 'PATH' ~/Library/LaunchAgents/com.agentlite.plist ``` If `/opt/homebrew/bin` is missing, add it to the `` value inside the `PATH` key in the plist. Then reload: + ```bash launchctl unload ~/Library/LaunchAgents/com.agentlite.plist launchctl load ~/Library/LaunchAgents/com.agentlite.plist @@ -125,6 +129,7 @@ tail -f logs/agentlite.log | grep -i -E "voice|transcri|whisper" ``` Look for: + - `Transcribed voice message` — successful transcription - `whisper.cpp transcription failed` — check model path, ffmpeg, or PATH @@ -132,14 +137,15 @@ Look for: Environment variables (optional, set in `.env`): -| Variable | Default | Description | -|----------|---------|-------------| -| `WHISPER_BIN` | `whisper-cli` | Path to whisper.cpp binary | -| `WHISPER_MODEL` | `data/models/ggml-base.bin` | Path to GGML model file | +| Variable | Default | Description | +| --------------- | --------------------------- | -------------------------- | +| `WHISPER_BIN` | `whisper-cli` | Path to whisper.cpp binary | +| `WHISPER_MODEL` | `data/models/ggml-base.bin` | Path to GGML model file | ## Troubleshooting **"whisper.cpp transcription failed"**: Ensure both `whisper-cli` and `ffmpeg` are in PATH. The launchd service uses a restricted PATH — see Phase 3 above. Test manually: + ```bash ffmpeg -f lavfi -i anullsrc=r=16000:cl=mono -t 1 -f wav /tmp/test.wav -y whisper-cli -m data/models/ggml-base.bin -f /tmp/test.wav --no-timestamps -nt diff --git a/.claude/skills/use-native-credential-proxy/SKILL.md b/.claude/skills/use-native-credential-proxy/SKILL.md index e097fe60370..43832e1f9c5 100644 --- a/.claude/skills/use-native-credential-proxy/SKILL.md +++ b/.claude/skills/use-native-credential-proxy/SKILL.md @@ -55,6 +55,7 @@ git merge upstream/skill/native-credential-proxy || { ``` This merges in: + - `src/credential-proxy.ts` and `src/credential-proxy.test.ts` (the proxy implementation) - Restored credential proxy usage in `src/orchestrator.ts`, `src/container-runner.ts`, `src/container-runtime.ts`, `src/config.ts` - Removed `@onecli-sh/sdk` dependency @@ -119,6 +120,7 @@ npm run build ``` Then restart the service: + - macOS: `launchctl kickstart -k gui/$(id -u)/com.agentlite` - Linux: `systemctl --user restart agentlite` - WSL/manual: stop and re-run `bash start-agentlite.sh` diff --git a/.claude/skills/x-integration/SKILL.md b/.claude/skills/x-integration/SKILL.md index 65897fcd25d..85d4c632b5b 100644 --- a/.claude/skills/x-integration/SKILL.md +++ b/.claude/skills/x-integration/SKILL.md @@ -11,13 +11,13 @@ Browser automation for X interactions via WhatsApp. ## Features -| Action | Tool | Description | -|--------|------|-------------| -| Post | `x_post` | Publish new tweets | -| Like | `x_like` | Like any tweet | -| Reply | `x_reply` | Reply to tweets | -| Retweet | `x_retweet` | Retweet without comment | -| Quote | `x_quote` | Quote tweet with comment | +| Action | Tool | Description | +| ------- | ----------- | ------------------------ | +| Post | `x_post` | Publish new tweets | +| Like | `x_like` | Like any tweet | +| Reply | `x_reply` | Reply to tweets | +| Retweet | `x_retweet` | Retweet without comment | +| Quote | `x_quote` | Quote tweet with comment | ## Prerequisites @@ -58,11 +58,11 @@ launchctl kickstart -k gui/$(id -u)/com.agentlite # macOS ### Environment Variables -| Variable | Default | Description | -|----------|---------|-------------| -| `CHROME_PATH` | `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` | Chrome executable path | -| `AGENTLITE_ROOT` | `process.cwd()` | Project root directory | -| `LOG_LEVEL` | `info` | Logging level (debug, info, warn, error) | +| Variable | Default | Description | +| ---------------- | -------------------------------------------------------------- | ---------------------------------------- | +| `CHROME_PATH` | `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` | Chrome executable path | +| `AGENTLITE_ROOT` | `process.cwd()` | Project root directory | +| `LOG_LEVEL` | `info` | Logging level (debug, info, warn, error) | Set in `.env` file (loaded via `dotenv-cli` at runtime): @@ -77,23 +77,23 @@ Edit `lib/config.ts` to modify defaults: ```typescript export const config = { - // Browser viewport - viewport: { width: 1280, height: 800 }, - - // Timeouts (milliseconds) - timeouts: { - navigation: 30000, // Page navigation - elementWait: 5000, // Wait for element - afterClick: 1000, // Delay after click - afterFill: 1000, // Delay after form fill - afterSubmit: 3000, // Delay after submit - pageLoad: 3000, // Initial page load - }, - - // Tweet limits - limits: { - tweetMaxLength: 280, - }, + // Browser viewport + viewport: { width: 1280, height: 800 }, + + // Timeouts (milliseconds) + timeouts: { + navigation: 30000, // Page navigation + elementWait: 5000, // Wait for element + afterClick: 1000, // Delay after click + afterFill: 1000, // Delay after form fill + afterSubmit: 3000, // Delay after submit + pageLoad: 3000, // Initial page load + }, + + // Tweet limits + limits: { + tweetMaxLength: 280, + }, }; ``` @@ -101,11 +101,11 @@ export const config = { Paths relative to project root: -| Path | Purpose | Git | -|------|---------|-----| -| `data/x-browser-profile/` | Chrome profile with X session | Ignored | -| `data/x-auth.json` | Auth state marker | Ignored | -| `logs/agentlite.log` | Service logs (contains X operation logs) | Ignored | +| Path | Purpose | Git | +| ------------------------- | ---------------------------------------- | ------- | +| `data/x-browser-profile/` | Chrome profile with X session | Ignored | +| `data/x-auth.json` | Auth state marker | Ignored | +| `logs/agentlite.log` | Service logs (contains X operation logs) | Ignored | ## Architecture @@ -161,11 +161,13 @@ To integrate this skill into AgentLite, make the following modifications: **1. Host side: `src/ipc.ts`** Add import after other local imports: + ```typescript import { handleXIpc } from '../.claude/skills/x-integration/host.js'; ``` Modify `processTaskIpc` function's switch statement default case: + ```typescript // Find: default: @@ -184,12 +186,14 @@ if (!handled) { **2. Container side: `container/agent-runner/src/ipc-mcp.ts`** Add import after `cron-parser` import: + ```typescript // @ts-ignore - Copied during Docker build from .claude/skills/x-integration/ import { createXTools } from './skills/x-integration/agent.js'; ``` Add to the end of tools array (before the closing `]`): + ```typescript ...createXTools({ groupFolder, isMain }) ``` @@ -199,6 +203,7 @@ Add to the end of tools array (before the closing `]`): **3. Build script: `container/build.sh`** Change build context from `container/` to project root (required to access `.claude/skills/`): + ```bash # Find: docker build -t "${IMAGE_NAME}:${TAG}" . @@ -213,6 +218,7 @@ docker build -t "${IMAGE_NAME}:${TAG}" -f container/Dockerfile . **4. Dockerfile: `container/Dockerfile`** First, update the build context paths (required to access `.claude/skills/` from project root): + ```dockerfile # Find: COPY agent-runner/package*.json ./ @@ -226,6 +232,7 @@ COPY container/agent-runner/ ./ ``` Then add COPY line after `COPY container/agent-runner/ ./` and before `RUN npm run build`: + ```dockerfile # Copy skill MCP tools COPY .claude/skills/x-integration/agent.ts ./src/skills/x-integration/ @@ -253,6 +260,7 @@ npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts This opens Chrome for manual X login. Session saved to `data/x-browser-profile/`. **Verify success:** + ```bash cat data/x-auth.json # Should show {"authenticated": true, ...} ``` @@ -264,6 +272,7 @@ cat data/x-auth.json # Should show {"authenticated": true, ...} ``` **Verify success:** + ```bash ./container/build.sh 2>&1 | grep -i "agent.ts" # Should show COPY line ``` @@ -277,6 +286,7 @@ launchctl kickstart -k gui/$(id -u)/com.agentlite # macOS ``` **Verify success:** + ```bash launchctl list | grep agentlite # macOS — should show PID and exit code 0 or - # Linux: systemctl --user status agentlite @@ -377,25 +387,25 @@ Default timeout is 2 minutes (120s). Increase in `host.ts`: const timer = setTimeout(() => { proc.kill('SIGTERM'); resolve({ success: false, message: 'Script timed out (120s)' }); -}, 120000); // ← Increase this value +}, 120000); // ← Increase this value ``` ### X UI Selector Changes If X updates their UI, selectors in scripts may break. Current selectors: -| Element | Selector | -|---------|----------| -| Tweet input | `[data-testid="tweetTextarea_0"]` | -| Post button | `[data-testid="tweetButtonInline"]` | -| Reply button | `[data-testid="reply"]` | -| Like | `[data-testid="like"]` | -| Unlike | `[data-testid="unlike"]` | -| Retweet | `[data-testid="retweet"]` | -| Unretweet | `[data-testid="unretweet"]` | -| Confirm retweet | `[data-testid="retweetConfirm"]` | -| Modal dialog | `[role="dialog"][aria-modal="true"]` | -| Modal submit | `[data-testid="tweetButton"]` | +| Element | Selector | +| --------------- | ------------------------------------ | +| Tweet input | `[data-testid="tweetTextarea_0"]` | +| Post button | `[data-testid="tweetButtonInline"]` | +| Reply button | `[data-testid="reply"]` | +| Like | `[data-testid="like"]` | +| Unlike | `[data-testid="unlike"]` | +| Retweet | `[data-testid="retweet"]` | +| Unretweet | `[data-testid="unretweet"]` | +| Confirm retweet | `[data-testid="retweetConfirm"]` | +| Modal dialog | `[role="dialog"][aria-modal="true"]` | +| Modal submit | `[data-testid="tweetButton"]` | ### Container Build Issues @@ -414,4 +424,4 @@ docker run agentlite-agent ls -la /app/src/skills/ - `data/x-browser-profile/` - Contains X session cookies (in `.gitignore`) - `data/x-auth.json` - Auth state marker (in `.gitignore`) - Only main group can use X tools (enforced in `agent.ts` and `host.ts`) -- Scripts run as subprocesses with limited environment \ No newline at end of file +- Scripts run as subprocesses with limited environment diff --git a/.claude/skills/x-integration/agent.ts b/.claude/skills/x-integration/agent.ts index 2b3ab5ad77a..b567651e0a1 100644 --- a/.claude/skills/x-integration/agent.ts +++ b/.claude/skills/x-integration/agent.ts @@ -29,7 +29,10 @@ function writeIpcFile(dir: string, data: object): string { return filename; } -async function waitForResult(requestId: string, maxWait = 60000): Promise<{ success: boolean; message: string }> { +async function waitForResult( + requestId: string, + maxWait = 60000, +): Promise<{ success: boolean; message: string }> { const resultFile = path.join(RESULTS_DIR, `${requestId}.json`); const pollInterval = 1000; let elapsed = 0; @@ -44,7 +47,7 @@ async function waitForResult(requestId: string, maxWait = 60000): Promise<{ succ return { success: false, message: `Failed to read result: ${err}` }; } } - await new Promise(resolve => setTimeout(resolve, pollInterval)); + await new Promise((resolve) => setTimeout(resolve, pollInterval)); elapsed += pollInterval; } @@ -70,20 +73,30 @@ export function createXTools(ctx: SkillToolsContext) { The host machine will execute the browser automation to post the tweet. Make sure the content is appropriate and within X's character limit (280 chars for text).`, { - content: z.string().max(280).describe('The tweet content to post (max 280 characters)') + content: z + .string() + .max(280) + .describe('The tweet content to post (max 280 characters)'), }, async (args: { content: string }) => { if (!isMain) { return { - content: [{ type: 'text', text: 'Only the main group can post tweets.' }], - isError: true + content: [ + { type: 'text', text: 'Only the main group can post tweets.' }, + ], + isError: true, }; } if (args.content.length > 280) { return { - content: [{ type: 'text', text: `Tweet exceeds 280 character limit (current: ${args.content.length})` }], - isError: true + content: [ + { + type: 'text', + text: `Tweet exceeds 280 character limit (current: ${args.content.length})`, + }, + ], + isError: true, }; } @@ -93,15 +106,15 @@ Make sure the content is appropriate and within X's character limit (280 chars f requestId, content: args.content, groupFolder, - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }); const result = await waitForResult(requestId); return { content: [{ type: 'text', text: result.message }], - isError: !result.success + isError: !result.success, }; - } + }, ), tool( @@ -110,13 +123,22 @@ Make sure the content is appropriate and within X's character limit (280 chars f Provide the tweet URL or tweet ID to like.`, { - tweet_url: z.string().describe('The tweet URL (e.g., https://x.com/user/status/123) or tweet ID') + tweet_url: z + .string() + .describe( + 'The tweet URL (e.g., https://x.com/user/status/123) or tweet ID', + ), }, async (args: { tweet_url: string }) => { if (!isMain) { return { - content: [{ type: 'text', text: 'Only the main group can interact with X.' }], - isError: true + content: [ + { + type: 'text', + text: 'Only the main group can interact with X.', + }, + ], + isError: true, }; } @@ -126,15 +148,15 @@ Provide the tweet URL or tweet ID to like.`, requestId, tweetUrl: args.tweet_url, groupFolder, - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }); const result = await waitForResult(requestId); return { content: [{ type: 'text', text: result.message }], - isError: !result.success + isError: !result.success, }; - } + }, ), tool( @@ -143,14 +165,26 @@ Provide the tweet URL or tweet ID to like.`, Provide the tweet URL and your reply content.`, { - tweet_url: z.string().describe('The tweet URL (e.g., https://x.com/user/status/123) or tweet ID'), - content: z.string().max(280).describe('The reply content (max 280 characters)') + tweet_url: z + .string() + .describe( + 'The tweet URL (e.g., https://x.com/user/status/123) or tweet ID', + ), + content: z + .string() + .max(280) + .describe('The reply content (max 280 characters)'), }, async (args: { tweet_url: string; content: string }) => { if (!isMain) { return { - content: [{ type: 'text', text: 'Only the main group can interact with X.' }], - isError: true + content: [ + { + type: 'text', + text: 'Only the main group can interact with X.', + }, + ], + isError: true, }; } @@ -161,15 +195,15 @@ Provide the tweet URL and your reply content.`, tweetUrl: args.tweet_url, content: args.content, groupFolder, - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }); const result = await waitForResult(requestId); return { content: [{ type: 'text', text: result.message }], - isError: !result.success + isError: !result.success, }; - } + }, ), tool( @@ -178,13 +212,22 @@ Provide the tweet URL and your reply content.`, Provide the tweet URL to retweet.`, { - tweet_url: z.string().describe('The tweet URL (e.g., https://x.com/user/status/123) or tweet ID') + tweet_url: z + .string() + .describe( + 'The tweet URL (e.g., https://x.com/user/status/123) or tweet ID', + ), }, async (args: { tweet_url: string }) => { if (!isMain) { return { - content: [{ type: 'text', text: 'Only the main group can interact with X.' }], - isError: true + content: [ + { + type: 'text', + text: 'Only the main group can interact with X.', + }, + ], + isError: true, }; } @@ -194,15 +237,15 @@ Provide the tweet URL to retweet.`, requestId, tweetUrl: args.tweet_url, groupFolder, - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }); const result = await waitForResult(requestId); return { content: [{ type: 'text', text: result.message }], - isError: !result.success + isError: !result.success, }; - } + }, ), tool( @@ -211,14 +254,26 @@ Provide the tweet URL to retweet.`, Retweet with your own comment added.`, { - tweet_url: z.string().describe('The tweet URL (e.g., https://x.com/user/status/123) or tweet ID'), - comment: z.string().max(280).describe('Your comment for the quote tweet (max 280 characters)') + tweet_url: z + .string() + .describe( + 'The tweet URL (e.g., https://x.com/user/status/123) or tweet ID', + ), + comment: z + .string() + .max(280) + .describe('Your comment for the quote tweet (max 280 characters)'), }, async (args: { tweet_url: string; comment: string }) => { if (!isMain) { return { - content: [{ type: 'text', text: 'Only the main group can interact with X.' }], - isError: true + content: [ + { + type: 'text', + text: 'Only the main group can interact with X.', + }, + ], + isError: true, }; } @@ -229,15 +284,15 @@ Retweet with your own comment added.`, tweetUrl: args.tweet_url, comment: args.comment, groupFolder, - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }); const result = await waitForResult(requestId); return { content: [{ type: 'text', text: result.message }], - isError: !result.success + isError: !result.success, }; - } - ) + }, + ), ]; } diff --git a/.claude/skills/x-integration/host.ts b/.claude/skills/x-integration/host.ts index 586eb8afd66..ea347c51dac 100644 --- a/.claude/skills/x-integration/host.ts +++ b/.claude/skills/x-integration/host.ts @@ -12,7 +12,7 @@ import pino from 'pino'; const logger = pino({ level: process.env.LOG_LEVEL || 'info', - transport: { target: 'pino-pretty', options: { colorize: true } } + transport: { target: 'pino-pretty', options: { colorize: true } }, }); interface SkillResult { @@ -23,17 +23,26 @@ interface SkillResult { // Run a skill script as subprocess async function runScript(script: string, args: object): Promise { - const scriptPath = path.join(process.cwd(), '.claude', 'skills', 'x-integration', 'scripts', `${script}.ts`); + const scriptPath = path.join( + process.cwd(), + '.claude', + 'skills', + 'x-integration', + 'scripts', + `${script}.ts`, + ); return new Promise((resolve) => { const proc = spawn('npx', ['tsx', scriptPath], { cwd: process.cwd(), env: { ...process.env, AGENTLITE_ROOT: process.cwd() }, - stdio: ['pipe', 'pipe', 'pipe'] + stdio: ['pipe', 'pipe', 'pipe'], }); let stdout = ''; - proc.stdout.on('data', (data) => { stdout += data.toString(); }); + proc.stdout.on('data', (data) => { + stdout += data.toString(); + }); proc.stdin.write(JSON.stringify(args)); proc.stdin.end(); @@ -45,14 +54,20 @@ async function runScript(script: string, args: object): Promise { proc.on('close', (code) => { clearTimeout(timer); if (code !== 0) { - resolve({ success: false, message: `Script exited with code: ${code}` }); + resolve({ + success: false, + message: `Script exited with code: ${code}`, + }); return; } try { const lines = stdout.trim().split('\n'); resolve(JSON.parse(lines[lines.length - 1])); } catch { - resolve({ success: false, message: `Failed to parse output: ${stdout.slice(0, 200)}` }); + resolve({ + success: false, + message: `Failed to parse output: ${stdout.slice(0, 200)}`, + }); } }); @@ -64,10 +79,18 @@ async function runScript(script: string, args: object): Promise { } // Write result to IPC results directory -function writeResult(dataDir: string, sourceGroup: string, requestId: string, result: SkillResult): void { +function writeResult( + dataDir: string, + sourceGroup: string, + requestId: string, + result: SkillResult, +): void { const resultsDir = path.join(dataDir, 'ipc', sourceGroup, 'x_results'); fs.mkdirSync(resultsDir, { recursive: true }); - fs.writeFileSync(path.join(resultsDir, `${requestId}.json`), JSON.stringify(result)); + fs.writeFileSync( + path.join(resultsDir, `${requestId}.json`), + JSON.stringify(result), + ); } /** @@ -79,7 +102,7 @@ export async function handleXIpc( data: Record, sourceGroup: string, isMain: boolean, - dataDir: string + dataDir: string, ): Promise { const type = data.type as string; @@ -126,7 +149,10 @@ export async function handleXIpc( result = { success: false, message: 'Missing tweetUrl or content' }; break; } - result = await runScript('reply', { tweetUrl: data.tweetUrl, content: data.content }); + result = await runScript('reply', { + tweetUrl: data.tweetUrl, + content: data.content, + }); break; case 'x_retweet': @@ -142,7 +168,10 @@ export async function handleXIpc( result = { success: false, message: 'Missing tweetUrl or comment' }; break; } - result = await runScript('quote', { tweetUrl: data.tweetUrl, comment: data.comment }); + result = await runScript('quote', { + tweetUrl: data.tweetUrl, + comment: data.comment, + }); break; default: @@ -153,7 +182,10 @@ export async function handleXIpc( if (result.success) { logger.info({ type, requestId }, 'X request completed'); } else { - logger.error({ type, requestId, message: result.message }, 'X request failed'); + logger.error( + { type, requestId, message: result.message }, + 'X request failed', + ); } return true; } diff --git a/.claude/skills/x-integration/lib/browser.ts b/.claude/skills/x-integration/lib/browser.ts index a7868cc22a1..000ee87d805 100644 --- a/.claude/skills/x-integration/lib/browser.ts +++ b/.claude/skills/x-integration/lib/browser.ts @@ -23,7 +23,9 @@ export async function readInput(): Promise { return new Promise((resolve, reject) => { let data = ''; process.stdin.setEncoding('utf8'); - process.stdin.on('data', chunk => { data += chunk; }); + process.stdin.on('data', (chunk) => { + data += chunk; + }); process.stdin.on('end', () => { try { resolve(JSON.parse(data)); @@ -46,10 +48,16 @@ export function writeResult(result: ScriptResult): void { * Clean up browser lock files */ export function cleanupLockFiles(): void { - for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) { + for (const lockFile of [ + 'SingletonLock', + 'SingletonSocket', + 'SingletonCookie', + ]) { const lockPath = path.join(config.browserDataDir, lockFile); if (fs.existsSync(lockPath)) { - try { fs.unlinkSync(lockPath); } catch {} + try { + fs.unlinkSync(lockPath); + } catch {} } } } @@ -57,12 +65,18 @@ export function cleanupLockFiles(): void { /** * Validate tweet/reply content */ -export function validateContent(content: string | undefined, type = 'Tweet'): ScriptResult | null { +export function validateContent( + content: string | undefined, + type = 'Tweet', +): ScriptResult | null { if (!content || content.length === 0) { return { success: false, message: `${type} content cannot be empty` }; } if (content.length > config.limits.tweetMaxLength) { - return { success: false, message: `${type} exceeds ${config.limits.tweetMaxLength} character limit (current: ${content.length})` }; + return { + success: false, + message: `${type} exceeds ${config.limits.tweetMaxLength} character limit (current: ${content.length})`, + }; } return null; // Valid } @@ -72,18 +86,23 @@ export function validateContent(content: string | undefined, type = 'Tweet'): Sc */ export async function getBrowserContext(): Promise { if (!fs.existsSync(config.authPath)) { - throw new Error('X authentication not configured. Run /x-integration to complete login.'); + throw new Error( + 'X authentication not configured. Run /x-integration to complete login.', + ); } cleanupLockFiles(); - const context = await chromium.launchPersistentContext(config.browserDataDir, { - executablePath: config.chromePath, - headless: false, - viewport: config.viewport, - args: config.chromeArgs, - ignoreDefaultArgs: config.chromeIgnoreDefaultArgs, - }); + const context = await chromium.launchPersistentContext( + config.browserDataDir, + { + executablePath: config.chromePath, + headless: false, + viewport: config.viewport, + args: config.chromeArgs, + ignoreDefaultArgs: config.chromeIgnoreDefaultArgs, + }, + ); return context; } @@ -103,9 +122,9 @@ export function extractTweetId(input: string): string | null { */ export async function navigateToTweet( context: BrowserContext, - tweetUrl: string + tweetUrl: string, ): Promise<{ page: Page; success: boolean; error?: string }> { - const page = context.pages()[0] || await context.newPage(); + const page = context.pages()[0] || (await context.newPage()); let url = tweetUrl; const tweetId = extractTweetId(tweetUrl); @@ -114,17 +133,33 @@ export async function navigateToTweet( } try { - await page.goto(url, { timeout: config.timeouts.navigation, waitUntil: 'domcontentloaded' }); + await page.goto(url, { + timeout: config.timeouts.navigation, + waitUntil: 'domcontentloaded', + }); await page.waitForTimeout(config.timeouts.pageLoad); - const exists = await page.locator('article[data-testid="tweet"]').first().isVisible().catch(() => false); + const exists = await page + .locator('article[data-testid="tweet"]') + .first() + .isVisible() + .catch(() => false); if (!exists) { - return { page, success: false, error: 'Tweet not found. It may have been deleted or the URL is invalid.' }; + return { + page, + success: false, + error: + 'Tweet not found. It may have been deleted or the URL is invalid.', + }; } return { page, success: true }; } catch (err) { - return { page, success: false, error: `Navigation failed: ${err instanceof Error ? err.message : String(err)}` }; + return { + page, + success: false, + error: `Navigation failed: ${err instanceof Error ? err.message : String(err)}`, + }; } } @@ -132,7 +167,7 @@ export async function navigateToTweet( * Run script with error handling */ export async function runScript( - handler: (input: T) => Promise + handler: (input: T) => Promise, ): Promise { try { const input = await readInput(); @@ -141,7 +176,7 @@ export async function runScript( } catch (err) { writeResult({ success: false, - message: `Script execution failed: ${err instanceof Error ? err.message : String(err)}` + message: `Script execution failed: ${err instanceof Error ? err.message : String(err)}`, }); process.exit(1); } diff --git a/.claude/skills/x-integration/lib/config.ts b/.claude/skills/x-integration/lib/config.ts index 162b2a899d1..7c87ca7bc65 100644 --- a/.claude/skills/x-integration/lib/config.ts +++ b/.claude/skills/x-integration/lib/config.ts @@ -17,7 +17,9 @@ export const config = { // Chrome executable path // Default: standard macOS Chrome location // Override: CHROME_PATH environment variable - chromePath: process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + chromePath: + process.env.CHROME_PATH || + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', // Browser profile directory for persistent login sessions browserDataDir: path.join(PROJECT_ROOT, 'data', 'x-browser-profile'), @@ -59,4 +61,3 @@ export const config = { // Args to ignore when launching Chrome chromeIgnoreDefaultArgs: ['--enable-automation'], }; - diff --git a/.claude/skills/x-integration/scripts/like.ts b/.claude/skills/x-integration/scripts/like.ts index c55b8b47817..4293f5eb612 100644 --- a/.claude/skills/x-integration/scripts/like.ts +++ b/.claude/skills/x-integration/scripts/like.ts @@ -4,7 +4,13 @@ * Usage: echo '{"tweetUrl":"https://x.com/user/status/123"}' | npx tsx like.ts */ -import { getBrowserContext, navigateToTweet, runScript, config, ScriptResult } from '../lib/browser.js'; +import { + getBrowserContext, + navigateToTweet, + runScript, + config, + ScriptResult, +} from '../lib/browser.js'; interface LikeInput { tweetUrl: string; @@ -46,8 +52,10 @@ async function likeTweet(input: LikeInput): Promise { return { success: true, message: 'Like successful' }; } - return { success: false, message: 'Like action completed but could not verify success' }; - + return { + success: false, + message: 'Like action completed but could not verify success', + }; } finally { if (context) await context.close(); } diff --git a/.claude/skills/x-integration/scripts/post.ts b/.claude/skills/x-integration/scripts/post.ts index f7b47dcef33..28baeb1224d 100644 --- a/.claude/skills/x-integration/scripts/post.ts +++ b/.claude/skills/x-integration/scripts/post.ts @@ -4,7 +4,13 @@ * Usage: echo '{"content":"Hello world"}' | npx tsx post.ts */ -import { getBrowserContext, runScript, validateContent, config, ScriptResult } from '../lib/browser.js'; +import { + getBrowserContext, + runScript, + validateContent, + config, + ScriptResult, +} from '../lib/browser.js'; interface PostInput { content: string; @@ -19,17 +25,29 @@ async function postTweet(input: PostInput): Promise { let context = null; try { context = await getBrowserContext(); - const page = context.pages()[0] || await context.newPage(); + const page = context.pages()[0] || (await context.newPage()); - await page.goto('https://x.com/home', { timeout: config.timeouts.navigation, waitUntil: 'domcontentloaded' }); + await page.goto('https://x.com/home', { + timeout: config.timeouts.navigation, + waitUntil: 'domcontentloaded', + }); await page.waitForTimeout(config.timeouts.pageLoad); // Check if logged in - const isLoggedIn = await page.locator('[data-testid="SideNav_AccountSwitcher_Button"]').isVisible().catch(() => false); + const isLoggedIn = await page + .locator('[data-testid="SideNav_AccountSwitcher_Button"]') + .isVisible() + .catch(() => false); if (!isLoggedIn) { - const onLoginPage = await page.locator('input[autocomplete="username"]').isVisible().catch(() => false); + const onLoginPage = await page + .locator('input[autocomplete="username"]') + .isVisible() + .catch(() => false); if (onLoginPage) { - return { success: false, message: 'X login expired. Run /x-integration to re-authenticate.' }; + return { + success: false, + message: 'X login expired. Run /x-integration to re-authenticate.', + }; } } @@ -47,7 +65,11 @@ async function postTweet(input: PostInput): Promise { const isDisabled = await postButton.getAttribute('aria-disabled'); if (isDisabled === 'true') { - return { success: false, message: 'Post button disabled. Content may be empty or exceed character limit.' }; + return { + success: false, + message: + 'Post button disabled. Content may be empty or exceed character limit.', + }; } await postButton.click(); @@ -55,9 +77,8 @@ async function postTweet(input: PostInput): Promise { return { success: true, - message: `Tweet posted: ${content.slice(0, 50)}${content.length > 50 ? '...' : ''}` + message: `Tweet posted: ${content.slice(0, 50)}${content.length > 50 ? '...' : ''}`, }; - } finally { if (context) await context.close(); } diff --git a/.claude/skills/x-integration/scripts/quote.ts b/.claude/skills/x-integration/scripts/quote.ts index e0d2c335801..12732f453d9 100644 --- a/.claude/skills/x-integration/scripts/quote.ts +++ b/.claude/skills/x-integration/scripts/quote.ts @@ -4,7 +4,14 @@ * Usage: echo '{"tweetUrl":"https://x.com/user/status/123","comment":"My thoughts"}' | npx tsx quote.ts */ -import { getBrowserContext, navigateToTweet, runScript, validateContent, config, ScriptResult } from '../lib/browser.js'; +import { + getBrowserContext, + navigateToTweet, + runScript, + validateContent, + config, + ScriptResult, +} from '../lib/browser.js'; interface QuoteInput { tweetUrl: string; @@ -38,7 +45,9 @@ async function quoteTweet(input: QuoteInput): Promise { await page.waitForTimeout(config.timeouts.afterClick); // Click quote option - const quoteOption = page.getByRole('menuitem').filter({ hasText: /Quote/i }); + const quoteOption = page + .getByRole('menuitem') + .filter({ hasText: /Quote/i }); await quoteOption.waitFor({ timeout: config.timeouts.elementWait }); await quoteOption.click(); await page.waitForTimeout(config.timeouts.afterClick * 1.5); @@ -61,7 +70,11 @@ async function quoteTweet(input: QuoteInput): Promise { const isDisabled = await submitButton.getAttribute('aria-disabled'); if (isDisabled === 'true') { - return { success: false, message: 'Submit button disabled. Content may be empty or exceed character limit.' }; + return { + success: false, + message: + 'Submit button disabled. Content may be empty or exceed character limit.', + }; } await submitButton.click(); @@ -69,9 +82,8 @@ async function quoteTweet(input: QuoteInput): Promise { return { success: true, - message: `Quote tweet posted: ${comment.slice(0, 50)}${comment.length > 50 ? '...' : ''}` + message: `Quote tweet posted: ${comment.slice(0, 50)}${comment.length > 50 ? '...' : ''}`, }; - } finally { if (context) await context.close(); } diff --git a/.claude/skills/x-integration/scripts/reply.ts b/.claude/skills/x-integration/scripts/reply.ts index e981cab5fc4..af159606114 100644 --- a/.claude/skills/x-integration/scripts/reply.ts +++ b/.claude/skills/x-integration/scripts/reply.ts @@ -4,7 +4,14 @@ * Usage: echo '{"tweetUrl":"https://x.com/user/status/123","content":"Great post!"}' | npx tsx reply.ts */ -import { getBrowserContext, navigateToTweet, runScript, validateContent, config, ScriptResult } from '../lib/browser.js'; +import { + getBrowserContext, + navigateToTweet, + runScript, + validateContent, + config, + ScriptResult, +} from '../lib/browser.js'; interface ReplyInput { tweetUrl: string; @@ -55,7 +62,11 @@ async function replyToTweet(input: ReplyInput): Promise { const isDisabled = await submitButton.getAttribute('aria-disabled'); if (isDisabled === 'true') { - return { success: false, message: 'Submit button disabled. Content may be empty or exceed character limit.' }; + return { + success: false, + message: + 'Submit button disabled. Content may be empty or exceed character limit.', + }; } await submitButton.click(); @@ -63,9 +74,8 @@ async function replyToTweet(input: ReplyInput): Promise { return { success: true, - message: `Reply posted: ${content.slice(0, 50)}${content.length > 50 ? '...' : ''}` + message: `Reply posted: ${content.slice(0, 50)}${content.length > 50 ? '...' : ''}`, }; - } finally { if (context) await context.close(); } diff --git a/.claude/skills/x-integration/scripts/retweet.ts b/.claude/skills/x-integration/scripts/retweet.ts index 05b74379e6f..0e57b694a35 100644 --- a/.claude/skills/x-integration/scripts/retweet.ts +++ b/.claude/skills/x-integration/scripts/retweet.ts @@ -4,7 +4,13 @@ * Usage: echo '{"tweetUrl":"https://x.com/user/status/123"}' | npx tsx retweet.ts */ -import { getBrowserContext, navigateToTweet, runScript, config, ScriptResult } from '../lib/browser.js'; +import { + getBrowserContext, + navigateToTweet, + runScript, + config, + ScriptResult, +} from '../lib/browser.js'; interface RetweetInput { tweetUrl: string; @@ -31,7 +37,9 @@ async function retweet(input: RetweetInput): Promise { const retweetButton = tweet.locator('[data-testid="retweet"]'); // Check if already retweeted - const alreadyRetweeted = await unretweetButton.isVisible().catch(() => false); + const alreadyRetweeted = await unretweetButton + .isVisible() + .catch(() => false); if (alreadyRetweeted) { return { success: true, message: 'Tweet already retweeted' }; } @@ -52,8 +60,10 @@ async function retweet(input: RetweetInput): Promise { return { success: true, message: 'Retweet successful' }; } - return { success: false, message: 'Retweet action completed but could not verify success' }; - + return { + success: false, + message: 'Retweet action completed but could not verify success', + }; } finally { if (context) await context.close(); } diff --git a/.claude/skills/x-integration/scripts/setup.ts b/.claude/skills/x-integration/scripts/setup.ts index 94e5c033768..903f7292ff8 100644 --- a/.claude/skills/x-integration/scripts/setup.ts +++ b/.claude/skills/x-integration/scripts/setup.ts @@ -27,29 +27,34 @@ async function setup(): Promise { console.log('Launching browser...\n'); - const context = await chromium.launchPersistentContext(config.browserDataDir, { - executablePath: config.chromePath, - headless: false, - viewport: config.viewport, - args: config.chromeArgs.slice(0, 3), // Use first 3 args for setup (less restrictive) - ignoreDefaultArgs: config.chromeIgnoreDefaultArgs, - }); - - const page = context.pages()[0] || await context.newPage(); + const context = await chromium.launchPersistentContext( + config.browserDataDir, + { + executablePath: config.chromePath, + headless: false, + viewport: config.viewport, + args: config.chromeArgs.slice(0, 3), // Use first 3 args for setup (less restrictive) + ignoreDefaultArgs: config.chromeIgnoreDefaultArgs, + }, + ); + + const page = context.pages()[0] || (await context.newPage()); // Navigate to login page await page.goto('https://x.com/login'); console.log('Please log in to X in the browser window.'); - console.log('After you see your home feed, come back here and press Enter.\n'); + console.log( + 'After you see your home feed, come back here and press Enter.\n', + ); // Wait for user to complete login const rl = readline.createInterface({ input: process.stdin, - output: process.stdout + output: process.stdout, }); - await new Promise(resolve => { + await new Promise((resolve) => { rl.question('Press Enter when logged in... ', () => { rl.close(); resolve(); @@ -61,14 +66,24 @@ async function setup(): Promise { await page.goto('https://x.com/home'); await page.waitForTimeout(config.timeouts.pageLoad); - const isLoggedIn = await page.locator('[data-testid="SideNav_AccountSwitcher_Button"]').isVisible().catch(() => false); + const isLoggedIn = await page + .locator('[data-testid="SideNav_AccountSwitcher_Button"]') + .isVisible() + .catch(() => false); if (isLoggedIn) { // Save auth marker - fs.writeFileSync(config.authPath, JSON.stringify({ - authenticated: true, - timestamp: new Date().toISOString() - }, null, 2)); + fs.writeFileSync( + config.authPath, + JSON.stringify( + { + authenticated: true, + timestamp: new Date().toISOString(), + }, + null, + 2, + ), + ); console.log('\n✅ Authentication successful!'); console.log(`Session saved to: ${config.browserDataDir}`); @@ -81,7 +96,7 @@ async function setup(): Promise { await context.close(); } -setup().catch(err => { +setup().catch((err) => { console.error('Setup failed:', err.message); process.exit(1); }); diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 49fe3667ab2..042a1868803 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,4 +1,5 @@ + ## Type of Change - [ ] **Feature skill** - adds a channel or integration (source code changes + SKILL.md) @@ -10,7 +11,6 @@ ## Description - ## For Skills - [ ] SKILL.md contains instructions, not inline code (code goes in separate files) diff --git a/.github/workflows/fork-sync-skills.yml b/.github/workflows/fork-sync-skills.yml index 47d082bc815..1f1ced5130e 100644 --- a/.github/workflows/fork-sync-skills.yml +++ b/.github/workflows/fork-sync-skills.yml @@ -6,7 +6,7 @@ on: types: [upstream-main-updated] # Fallback: run on a schedule in case dispatch isn't configured schedule: - - cron: '0 */6 * * *' # every 6 hours + - cron: '0 */6 * * *' # every 6 hours # Also run when fork's main is pushed directly push: branches: [main] @@ -253,4 +253,4 @@ jobs: title: `Merge-forward failed for ${failed.length} skill branch(es)`, body, labels: ['skill-maintenance'] - }); \ No newline at end of file + }); diff --git a/CHANGELOG.md b/CHANGELOG.md index 960b115669e..93c1c0e0a42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,4 +5,5 @@ All notable changes to AgentLite will be documented in this file. ## [1.2.0](https://github.com/boxlite-ai/agentlite/compare/v1.1.6...v1.2.0) [BREAKING] WhatsApp removed from core, now a skill. Run `/add-whatsapp` to re-add (existing auth/groups preserved). + - **fix:** Prevent scheduled tasks from executing twice when container runtime exceeds poll interval (#138, #669) diff --git a/CLAUDE.md b/CLAUDE.md index 0810a3b8b24..5eceb19845b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,26 +8,26 @@ Single Node.js process. Two-level API: `createAgentLite()` returns a platform in ## Key Files -| File | Purpose | -|------|---------| -| `src/api/sdk.ts` | Public API: `createAgentLite()`, `AgentLite` interface | -| `src/api/agent.ts` | Public API: `Agent` interface | -| `src/api/channel-driver.ts` | Public API: `ChannelDriver` interface | -| `src/api/options.ts` | Public API: `AgentLiteOptions`, `AgentOptions` | -| `src/api/channels/telegram.ts` | Public API: `telegram()` factory | -| `src/agentlite-impl.ts` | AgentLite implementation (not exported) | -| `src/agent-impl.ts` | Agent implementation: channels, message loop, groups | -| `src/agent-config.ts` | Immutable per-agent config (paths, identity, credentials) | -| `src/runtime-config.ts` | Immutable shared runtime config (box, timeouts) | -| `src/cli.ts` | CLI entry point (bin): process handlers, channel auto-discovery | -| `src/box-runtime.ts` | BoxLite VM runtime management | -| `src/container-runner.ts` | Spawns agent VMs with volume mounts | -| `src/ipc.ts` | IPC watcher and task processing | -| `src/router.ts` | Message formatting and outbound routing | -| `src/task-scheduler.ts` | Runs scheduled tasks | -| `src/db.ts` | SQLite operations | -| `groups/{name}/CLAUDE.md` | Per-group memory (isolated) | -| `container/skills/` | Skills loaded inside agent VMs (browser, status, formatting) | +| File | Purpose | +| ------------------------------ | --------------------------------------------------------------- | +| `src/api/sdk.ts` | Public API: `createAgentLite()`, `AgentLite` interface | +| `src/api/agent.ts` | Public API: `Agent` interface | +| `src/api/channel-driver.ts` | Public API: `ChannelDriver` interface | +| `src/api/options.ts` | Public API: `AgentLiteOptions`, `AgentOptions` | +| `src/api/channels/telegram.ts` | Public API: `telegram()` factory | +| `src/agentlite-impl.ts` | AgentLite implementation (not exported) | +| `src/agent-impl.ts` | Agent implementation: channels, message loop, groups | +| `src/agent-config.ts` | Immutable per-agent config (paths, identity, credentials) | +| `src/runtime-config.ts` | Immutable shared runtime config (box, timeouts) | +| `src/cli.ts` | CLI entry point (bin): process handlers, channel auto-discovery | +| `src/box-runtime.ts` | BoxLite VM runtime management | +| `src/container-runner.ts` | Spawns agent VMs with volume mounts | +| `src/ipc.ts` | IPC watcher and task processing | +| `src/router.ts` | Message formatting and outbound routing | +| `src/task-scheduler.ts` | Runs scheduled tasks | +| `src/db.ts` | SQLite operations | +| `groups/{name}/CLAUDE.md` | Per-group memory (isolated) | +| `container/skills/` | Skills loaded inside agent VMs (browser, status, formatting) | ## Secrets / Credentials / Proxy (OneCLI) @@ -42,14 +42,14 @@ Four types of skills exist in AgentLite. See [CONTRIBUTING.md](CONTRIBUTING.md) - **Operational skills** — instruction-only workflows, always on `main` (e.g. `/setup`, `/debug`) - **Container skills** — loaded inside agent containers at runtime (`container/skills/`) -| Skill | When to Use | -|-------|-------------| -| `/setup` | First-time installation, authentication, service configuration | -| `/customize` | Adding channels, integrations, changing behavior | -| `/debug` | Container issues, logs, troubleshooting | -| `/update-agentlite` | Bring upstream AgentLite updates into a customized install | -| `/qodo-pr-resolver` | Fetch and fix Qodo PR review issues interactively or in batch | -| `/get-qodo-rules` | Load org- and repo-level coding rules from Qodo before code tasks | +| Skill | When to Use | +| ------------------- | ----------------------------------------------------------------- | +| `/setup` | First-time installation, authentication, service configuration | +| `/customize` | Adding channels, integrations, changing behavior | +| `/debug` | Container issues, logs, troubleshooting | +| `/update-agentlite` | Bring upstream AgentLite updates into a customized install | +| `/qodo-pr-resolver` | Fetch and fix Qodo PR review issues interactively or in batch | +| `/get-qodo-rules` | Load org- and repo-level coding rules from Qodo before code tasks | ## Contributing @@ -66,6 +66,7 @@ npm run build # Compile TypeScript ``` Service management: + ```bash # macOS (launchd) launchctl load ~/Library/LaunchAgents/com.agentlite.plist diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 711f28007da..5e33bac1729 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,10 +3,12 @@ ## Before You Start 1. **Check for existing work.** Search open PRs and issues before starting: + ```bash gh pr list --repo boxlite-ai/agentlite --search "" gh issue list --repo boxlite-ai/agentlite --search "" ``` + If a related PR or issue exists, build on it rather than duplicating effort. 2. **Check alignment.** Read the [Philosophy section in README.md](README.md#philosophy). Source code changes should only be things 90%+ of users need. Skills can be more niche, but should still be useful beyond a single person's setup. @@ -38,11 +40,13 @@ Add capabilities to AgentLite by merging a git branch. The SKILL.md contains set **Examples:** `/add-telegram`, `/add-slack`, `/add-discord`, `/add-gmail` **How they work:** + 1. User runs `/add-telegram` 2. Claude follows the SKILL.md: fetches and merges the `skill/telegram` branch 3. Claude walks through interactive setup (env vars, bot creation, etc.) **Contributing a feature skill:** + 1. Fork `boxlite-ai/agentlite` and branch from `main` 2. Make the code changes (new files, modified source, updated `package.json`, etc.) 3. Add a SKILL.md in `.claude/skills//` with setup instructions — step 1 should be merging the branch @@ -61,6 +65,7 @@ Standalone tools that ship code files alongside the SKILL.md. The SKILL.md tells **Key difference from feature skills:** No branch merge needed. The code is self-contained in the skill directory and gets copied into place during installation. **Guidelines:** + - Put code in separate files, not inline in the SKILL.md - Use `${CLAUDE_SKILL_DIR}` to reference files in the skill directory - SKILL.md contains installation instructions, usage docs, and troubleshooting @@ -74,6 +79,7 @@ Workflows and guides with no code changes. The SKILL.md is the entire skill — **Examples:** `/setup`, `/debug`, `/customize`, `/update-agentlite`, `/update-skills` **Guidelines:** + - Pure instructions — no code files, no branch merges - Use `AskUserQuestion` for interactive prompts - These stay on `main` and are always available to every user @@ -89,6 +95,7 @@ Skills that run inside the agent container, not on the host. These teach the con **Key difference:** These are NOT invoked by the user on the host. They're loaded by Claude Code inside the container and influence how the agent behaves. **Guidelines:** + - Follow the same SKILL.md + frontmatter format - Use `allowed-tools` frontmatter to scope tool permissions - Keep them focused — the agent's context window is shared across all container skills @@ -107,6 +114,7 @@ Instructions here... ``` **Rules:** + - Keep SKILL.md **under 500 lines** — move detail to separate reference files - `name`: lowercase, alphanumeric + hyphens, max 64 chars - `description`: required — Claude uses this to decide when to invoke the skill @@ -125,14 +133,14 @@ Test your contribution on a fresh clone before submitting. For skills, run the s 2. **Test thoroughly.** Run the feature yourself. For skills, test on a fresh clone. 3. **Check the right box** in the PR template. Labels are auto-applied based on your selection: -| Checkbox | Label | -|----------|-------| -| Feature skill | `PR: Skill` + `PR: Feature` | -| Utility skill | `PR: Skill` | -| Operational/container skill | `PR: Skill` | -| Fix | `PR: Fix` | -| Simplification | `PR: Refactor` | -| Documentation | `PR: Docs` | +| Checkbox | Label | +| --------------------------- | --------------------------- | +| Feature skill | `PR: Skill` + `PR: Feature` | +| Utility skill | `PR: Skill` | +| Operational/container skill | `PR: Skill` | +| Fix | `PR: Fix` | +| Simplification | `PR: Refactor` | +| Documentation | `PR: Docs` | ### PR description diff --git a/README_ja.md b/README_ja.md index e98383de745..65f3790dccf 100644 --- a/README_ja.md +++ b/README_ja.md @@ -20,11 +20,13 @@

各エージェントはマイクロVM内の独立したコンテナで実行されます。
ハイパーバイザーレベルの分離。ミリ秒で起動。複雑なセットアップ不要。

**macOS (Apple Silicon)** + ```bash curl -fsSL https://agentlite.dev/install-docker-sandboxes.sh | bash ``` **Windows (WSL)** + ```bash curl -fsSL https://agentlite.dev/install-docker-sandboxes-windows.sh | bash ``` @@ -74,6 +76,7 @@ claude **カスタマイズ=コード変更。** 設定ファイルの肥大化なし。動作を変えたい?コードを変更するだけ。コードベースは変更しても安全な規模です。 **AIネイティブ。** + - インストールウィザードなし — Claude Codeがセットアップを案内。 - モニタリングダッシュボードなし — Claudeに状況を聞くだけ。 - デバッグツールなし — 問題を説明すればClaudeが修正。 @@ -104,6 +107,7 @@ claude ``` メインチャネル(セルフチャット)から、グループやタスクを管理できます: + ``` @Andy 全グループのスケジュールタスクを一覧表示して @Andy 月曜のブリーフィングタスクを一時停止して @@ -136,9 +140,11 @@ Telegram対応を追加したい場合、コアコードベースにTelegramを 私たちが求めているスキル: **コミュニケーションチャネル** + - `/add-signal` - Signalをチャネルとして追加 **セッション管理** + - `/clear` - 会話をコンパクト化する`/clear`コマンドの追加(同一セッション内で重要な情報を保持しながらコンテキストを要約)。Claude Agent SDKを通じてプログラム的にコンパクト化をトリガーする方法の解明が必要。 ## 必要条件 @@ -159,6 +165,7 @@ Telegram対応を追加したい場合、コアコードベースにTelegramを 詳細なアーキテクチャについては、[docs/SPEC.md](docs/SPEC.md)を参照してください。 主要ファイル: + - `src/orchestrator.ts` - オーケストレーター:状態、メッセージループ、エージェント呼び出し - `src/channels/registry.ts` - チャネルレジストリ(起動時の自己登録) - `src/ipc.ts` - IPCウォッチャーとタスク処理 @@ -197,6 +204,7 @@ ANTHROPIC_AUTH_TOKEN=your-token-here ``` 以下が使用可能です: + - [Ollama](https://ollama.ai)とAPIプロキシ経由のローカルモデル - [Together AI](https://together.ai)、[Fireworks](https://fireworks.ai)等でホストされたオープンソースモデル - Anthropic互換APIのカスタムモデルデプロイメント diff --git a/README_zh.md b/README_zh.md index d4a746316ae..9ba801cc173 100644 --- a/README_zh.md +++ b/README_zh.md @@ -73,6 +73,7 @@ claude ``` 在主频道(您的self-chat)中,可以管理群组和任务: + ``` @Andy 列出所有群组的计划任务 @Andy 暂停周一简报任务 @@ -105,9 +106,11 @@ claude 我们希望看到的技能: **通信渠道** + - `/add-signal` - 添加 Signal 作为渠道 **会话管理** + - `/clear` - 添加一个 `/clear` 命令,用于压缩会话(在同一会话中总结上下文,同时保留关键信息)。这需要研究如何通过 Claude Agent SDK 以编程方式触发压缩。 ## 系统要求 @@ -128,6 +131,7 @@ claude 完整架构详情请见 [docs/SPEC.md](docs/SPEC.md)。 关键文件: + - `src/orchestrator.ts` - 编排器:状态管理、消息循环、智能体调用 - `src/channels/registry.ts` - 渠道注册表(启动时自注册) - `src/ipc.ts` - IPC 监听与任务处理 @@ -166,6 +170,7 @@ ANTHROPIC_AUTH_TOKEN=your-token-here ``` 这使您能够使用: + - 通过 [Ollama](https://ollama.ai) 配合 API 代理运行的本地模型 - 托管在 [Together AI](https://together.ai)、[Fireworks](https://fireworks.ai) 等平台上的开源模型 - 兼容 Anthropic API 格式的自定义模型部署 diff --git a/config-examples/mount-allowlist.json b/config-examples/mount-allowlist.json index e914883d340..e1ec5e8e364 100644 --- a/config-examples/mount-allowlist.json +++ b/config-examples/mount-allowlist.json @@ -16,10 +16,6 @@ "description": "Work documents (read-only)" } ], - "blockedPatterns": [ - "password", - "secret", - "token" - ], + "blockedPatterns": ["password", "secret", "token"], "nonMainReadOnly": true } diff --git a/container/agent-runner/src/ipc-mcp-stdio.ts b/container/agent-runner/src/ipc-mcp-stdio.ts index 2b6daddbb35..e51c600c39c 100644 --- a/container/agent-runner/src/ipc-mcp-stdio.ts +++ b/container/agent-runner/src/ipc-mcp-stdio.ts @@ -44,7 +44,12 @@ server.tool( "Send a message to the user or group immediately while you're still running. Use this for progress updates or to send multiple messages. You can call this multiple times.", { text: z.string().describe('The message text to send'), - sender: z.string().optional().describe('Your role/identity name (e.g. "Researcher"). When set, messages appear from a dedicated bot in Telegram.'), + sender: z + .string() + .optional() + .describe( + 'Your role/identity name (e.g. "Researcher"). When set, messages appear from a dedicated bot in Telegram.', + ), }, async (args) => { const data: Record = { @@ -86,11 +91,33 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone): \u2022 interval: Milliseconds between runs (e.g., "300000" for 5 minutes, "3600000" for 1 hour) \u2022 once: Local time WITHOUT "Z" suffix (e.g., "2026-02-01T15:30:00"). Do NOT use UTC/Z suffix.`, { - prompt: z.string().describe('What the agent should do when the task runs. For isolated mode, include all necessary context here.'), - schedule_type: z.enum(['cron', 'interval', 'once']).describe('cron=recurring at specific times, interval=recurring every N ms, once=run once at specific time'), - schedule_value: z.string().describe('cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)'), - context_mode: z.enum(['group', 'isolated']).default('group').describe('group=runs with chat history and memory, isolated=fresh session (include context in prompt)'), - target_group_jid: z.string().optional().describe('(Main group only) JID of the group to schedule the task for. Defaults to the current group.'), + prompt: z + .string() + .describe( + 'What the agent should do when the task runs. For isolated mode, include all necessary context here.', + ), + schedule_type: z + .enum(['cron', 'interval', 'once']) + .describe( + 'cron=recurring at specific times, interval=recurring every N ms, once=run once at specific time', + ), + schedule_value: z + .string() + .describe( + 'cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)', + ), + context_mode: z + .enum(['group', 'isolated']) + .default('group') + .describe( + 'group=runs with chat history and memory, isolated=fresh session (include context in prompt)', + ), + target_group_jid: z + .string() + .optional() + .describe( + '(Main group only) JID of the group to schedule the task for. Defaults to the current group.', + ), }, async (args) => { // Validate schedule_value before writing IPC @@ -99,7 +126,12 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone): CronExpressionParser.parse(args.schedule_value); } catch { return { - content: [{ type: 'text' as const, text: `Invalid cron: "${args.schedule_value}". Use format like "0 9 * * *" (daily 9am) or "*/5 * * * *" (every 5 min).` }], + content: [ + { + type: 'text' as const, + text: `Invalid cron: "${args.schedule_value}". Use format like "0 9 * * *" (daily 9am) or "*/5 * * * *" (every 5 min).`, + }, + ], isError: true, }; } @@ -107,28 +139,47 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone): const ms = parseInt(args.schedule_value, 10); if (isNaN(ms) || ms <= 0) { return { - content: [{ type: 'text' as const, text: `Invalid interval: "${args.schedule_value}". Must be positive milliseconds (e.g., "300000" for 5 min).` }], + content: [ + { + type: 'text' as const, + text: `Invalid interval: "${args.schedule_value}". Must be positive milliseconds (e.g., "300000" for 5 min).`, + }, + ], isError: true, }; } } else if (args.schedule_type === 'once') { - if (/[Zz]$/.test(args.schedule_value) || /[+-]\d{2}:\d{2}$/.test(args.schedule_value)) { + if ( + /[Zz]$/.test(args.schedule_value) || + /[+-]\d{2}:\d{2}$/.test(args.schedule_value) + ) { return { - content: [{ type: 'text' as const, text: `Timestamp must be local time without timezone suffix. Got "${args.schedule_value}" — use format like "2026-02-01T15:30:00".` }], + content: [ + { + type: 'text' as const, + text: `Timestamp must be local time without timezone suffix. Got "${args.schedule_value}" — use format like "2026-02-01T15:30:00".`, + }, + ], isError: true, }; } const date = new Date(args.schedule_value); if (isNaN(date.getTime())) { return { - content: [{ type: 'text' as const, text: `Invalid timestamp: "${args.schedule_value}". Use local time format like "2026-02-01T15:30:00".` }], + content: [ + { + type: 'text' as const, + text: `Invalid timestamp: "${args.schedule_value}". Use local time format like "2026-02-01T15:30:00".`, + }, + ], isError: true, }; } } // Non-main groups can only schedule for themselves - const targetJid = isMain && args.target_group_jid ? args.target_group_jid : chatJid; + const targetJid = + isMain && args.target_group_jid ? args.target_group_jid : chatJid; const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; @@ -147,7 +198,12 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone): writeIpcFile(TASKS_DIR, data); return { - content: [{ type: 'text' as const, text: `Task ${taskId} scheduled: ${args.schedule_type} - ${args.schedule_value}` }], + content: [ + { + type: 'text' as const, + text: `Task ${taskId} scheduled: ${args.schedule_type} - ${args.schedule_value}`, + }, + ], }; }, ); @@ -161,30 +217,56 @@ server.tool( try { if (!fs.existsSync(tasksFile)) { - return { content: [{ type: 'text' as const, text: 'No scheduled tasks found.' }] }; + return { + content: [ + { type: 'text' as const, text: 'No scheduled tasks found.' }, + ], + }; } const allTasks = JSON.parse(fs.readFileSync(tasksFile, 'utf-8')); const tasks = isMain ? allTasks - : allTasks.filter((t: { groupFolder: string }) => t.groupFolder === groupFolder); + : allTasks.filter( + (t: { groupFolder: string }) => t.groupFolder === groupFolder, + ); if (tasks.length === 0) { - return { content: [{ type: 'text' as const, text: 'No scheduled tasks found.' }] }; + return { + content: [ + { type: 'text' as const, text: 'No scheduled tasks found.' }, + ], + }; } const formatted = tasks .map( - (t: { id: string; prompt: string; schedule_type: string; schedule_value: string; status: string; next_run: string }) => + (t: { + id: string; + prompt: string; + schedule_type: string; + schedule_value: string; + status: string; + next_run: string; + }) => `- [${t.id}] ${t.prompt.slice(0, 50)}... (${t.schedule_type}: ${t.schedule_value}) - ${t.status}, next: ${t.next_run || 'N/A'}`, ) .join('\n'); - return { content: [{ type: 'text' as const, text: `Scheduled tasks:\n${formatted}` }] }; + return { + content: [ + { type: 'text' as const, text: `Scheduled tasks:\n${formatted}` }, + ], + }; } catch (err) { return { - content: [{ type: 'text' as const, text: `Error reading tasks: ${err instanceof Error ? err.message : String(err)}` }], + content: [ + { + type: 'text' as const, + text: `Error reading tasks: ${err instanceof Error ? err.message : String(err)}`, + }, + ], }; } }, @@ -205,7 +287,14 @@ server.tool( writeIpcFile(TASKS_DIR, data); - return { content: [{ type: 'text' as const, text: `Task ${args.task_id} pause requested.` }] }; + return { + content: [ + { + type: 'text' as const, + text: `Task ${args.task_id} pause requested.`, + }, + ], + }; }, ); @@ -224,7 +313,14 @@ server.tool( writeIpcFile(TASKS_DIR, data); - return { content: [{ type: 'text' as const, text: `Task ${args.task_id} resume requested.` }] }; + return { + content: [ + { + type: 'text' as const, + text: `Task ${args.task_id} resume requested.`, + }, + ], + }; }, ); @@ -243,7 +339,14 @@ server.tool( writeIpcFile(TASKS_DIR, data); - return { content: [{ type: 'text' as const, text: `Task ${args.task_id} cancellation requested.` }] }; + return { + content: [ + { + type: 'text' as const, + text: `Task ${args.task_id} cancellation requested.`, + }, + ], + }; }, ); @@ -253,18 +356,32 @@ server.tool( { task_id: z.string().describe('The task ID to update'), prompt: z.string().optional().describe('New prompt for the task'), - schedule_type: z.enum(['cron', 'interval', 'once']).optional().describe('New schedule type'), - schedule_value: z.string().optional().describe('New schedule value (see schedule_task for format)'), + schedule_type: z + .enum(['cron', 'interval', 'once']) + .optional() + .describe('New schedule type'), + schedule_value: z + .string() + .optional() + .describe('New schedule value (see schedule_task for format)'), }, async (args) => { // Validate schedule_value if provided - if (args.schedule_type === 'cron' || (!args.schedule_type && args.schedule_value)) { + if ( + args.schedule_type === 'cron' || + (!args.schedule_type && args.schedule_value) + ) { if (args.schedule_value) { try { CronExpressionParser.parse(args.schedule_value); } catch { return { - content: [{ type: 'text' as const, text: `Invalid cron: "${args.schedule_value}".` }], + content: [ + { + type: 'text' as const, + text: `Invalid cron: "${args.schedule_value}".`, + }, + ], isError: true, }; } @@ -274,7 +391,12 @@ server.tool( const ms = parseInt(args.schedule_value, 10); if (isNaN(ms) || ms <= 0) { return { - content: [{ type: 'text' as const, text: `Invalid interval: "${args.schedule_value}".` }], + content: [ + { + type: 'text' as const, + text: `Invalid interval: "${args.schedule_value}".`, + }, + ], isError: true, }; } @@ -288,12 +410,21 @@ server.tool( timestamp: new Date().toISOString(), }; if (args.prompt !== undefined) data.prompt = args.prompt; - if (args.schedule_type !== undefined) data.schedule_type = args.schedule_type; - if (args.schedule_value !== undefined) data.schedule_value = args.schedule_value; + if (args.schedule_type !== undefined) + data.schedule_type = args.schedule_type; + if (args.schedule_value !== undefined) + data.schedule_value = args.schedule_value; writeIpcFile(TASKS_DIR, data); - return { content: [{ type: 'text' as const, text: `Task ${args.task_id} update requested.` }] }; + return { + content: [ + { + type: 'text' as const, + text: `Task ${args.task_id} update requested.`, + }, + ], + }; }, ); @@ -303,15 +434,28 @@ server.tool( Use available_groups.json to find the JID for a group. The folder name must be channel-prefixed: "{channel}_{group-name}" (e.g., "whatsapp_family-chat", "telegram_dev-team", "discord_general"). Use lowercase with hyphens for the group name part.`, { - jid: z.string().describe('The chat JID (e.g., "120363336345536173@g.us", "tg:-1001234567890", "dc:1234567890123456")'), + jid: z + .string() + .describe( + 'The chat JID (e.g., "120363336345536173@g.us", "tg:-1001234567890", "dc:1234567890123456")', + ), name: z.string().describe('Display name for the group'), - folder: z.string().describe('Channel-prefixed folder name (e.g., "whatsapp_family-chat", "telegram_dev-team")'), + folder: z + .string() + .describe( + 'Channel-prefixed folder name (e.g., "whatsapp_family-chat", "telegram_dev-team")', + ), trigger: z.string().describe('Trigger word (e.g., "@Andy")'), }, async (args) => { if (!isMain) { return { - content: [{ type: 'text' as const, text: 'Only the main group can register new groups.' }], + content: [ + { + type: 'text' as const, + text: 'Only the main group can register new groups.', + }, + ], isError: true, }; } @@ -328,12 +472,16 @@ Use available_groups.json to find the JID for a group. The folder name must be c writeIpcFile(TASKS_DIR, data); return { - content: [{ type: 'text' as const, text: `Group "${args.name}" registered. It will start receiving messages immediately.` }], + content: [ + { + type: 'text' as const, + text: `Group "${args.name}" registered. It will start receiving messages immediately.`, + }, + ], }; }, ); - // ─── Custom actions (HTTP transport) ──────────────────────────── /** diff --git a/container/skills/capabilities/SKILL.md b/container/skills/capabilities/SKILL.md index 14fdb3cbf62..a6feb51414b 100644 --- a/container/skills/capabilities/SKILL.md +++ b/container/skills/capabilities/SKILL.md @@ -14,6 +14,7 @@ test -d /workspace/project && echo "MAIN" || echo "NOT_MAIN" ``` If `NOT_MAIN`, respond with: + > This command is available in your main chat only. Send `/capabilities` there to see what I can do. Then stop — do not generate the report. @@ -35,15 +36,17 @@ Each directory is an installed skill. The directory name is the skill name (e.g. ### 2. Available tools Read the allowed tools from your SDK configuration. You always have access to: + - **Core:** Bash, Read, Write, Edit, Glob, Grep - **Web:** WebSearch, WebFetch - **Orchestration:** Task, TaskOutput, TaskStop, TeamCreate, TeamDelete, SendMessage - **Other:** TodoWrite, ToolSearch, Skill, NotebookEdit -- **MCP:** mcp__agentlite__* (messaging, tasks, group management) +- **MCP:** mcp**agentlite**\* (messaging, tasks, group management) ### 3. MCP server tools The AgentLite MCP server exposes these tools (via `mcp__agentlite__*` prefix): + - `send_message` — send a message to the user/group immediately (optional `sender` for identity labeling) - `schedule_task` — schedule a recurring or one-time task (cron / interval / once; group or isolated context) - `list_tasks` — list scheduled tasks (main sees all, other groups see their own) diff --git a/container/skills/slack-formatting/SKILL.md b/container/skills/slack-formatting/SKILL.md index 29a1b871694..9f27eeda576 100644 --- a/container/skills/slack-formatting/SKILL.md +++ b/container/skills/slack-formatting/SKILL.md @@ -10,6 +10,7 @@ When responding to Slack channels, use Slack's mrkdwn syntax instead of standard ## How to detect Slack context Check your group folder name or workspace path: + - Folder starts with `slack_` (e.g., `slack_engineering`, `slack_general`) - Or check `/workspace/group/` path for `slack_` prefix @@ -17,13 +18,13 @@ Check your group folder name or workspace path: ### Text styles -| Style | Syntax | Example | -|-------|--------|---------| -| Bold | `*text*` | *bold text* | -| Italic | `_text_` | _italic text_ | -| Strikethrough | `~text~` | ~strikethrough~ | -| Code (inline) | `` `code` `` | `inline code` | -| Code block | ` ```code``` ` | Multi-line code | +| Style | Syntax | Example | +| ------------- | -------------- | --------------- | +| Bold | `*text*` | _bold text_ | +| Italic | `_text_` | _italic text_ | +| Strikethrough | `~text~` | ~strikethrough~ | +| Code (inline) | `` `code` `` | `inline code` | +| Code block | ` ```code``` ` | Multi-line code | ### Links and mentions diff --git a/container/skills/status/SKILL.md b/container/skills/status/SKILL.md index dde566aa49c..d369c9815e1 100644 --- a/container/skills/status/SKILL.md +++ b/container/skills/status/SKILL.md @@ -14,6 +14,7 @@ test -d /workspace/project && echo "MAIN" || echo "NOT_MAIN" ``` If `NOT_MAIN`, respond with: + > This command is available in your main chat only. Send `/status` there to check system status. Then stop — do not generate the report. @@ -50,7 +51,7 @@ Confirm which tool families are available to you: - **Core:** Bash, Read, Write, Edit, Glob, Grep - **Web:** WebSearch, WebFetch - **Orchestration:** Task, TaskOutput, TaskStop, TeamCreate, TeamDelete, SendMessage -- **MCP:** mcp__agentlite__* (send_message, schedule_task, list_tasks, pause_task, resume_task, cancel_task, update_task, register_group, search_actions, call_action) +- **MCP:** mcp**agentlite**\* (send_message, schedule_task, list_tasks, pause_task, resume_task, cancel_task, update_task, register_group, search_actions, call_action) ### 4. Container utilities diff --git a/docs/APPLE-CONTAINER-NETWORKING.md b/docs/APPLE-CONTAINER-NETWORKING.md index f9ac9fd487f..e0693acee58 100644 --- a/docs/APPLE-CONTAINER-NETWORKING.md +++ b/docs/APPLE-CONTAINER-NETWORKING.md @@ -21,11 +21,13 @@ echo "nat on en0 from 192.168.64.0/24 to any -> (en0)" | sudo pfctl -ef - These settings reset on reboot. To make them permanent: **IP Forwarding** — add to `/etc/sysctl.conf`: + ``` net.inet.ip.forwarding=1 ``` **NAT Rules** — add to `/etc/pf.conf` (before any existing rules): + ``` nat on en0 from 192.168.64.0/24 to any -> (en0) ``` @@ -37,6 +39,7 @@ Then reload: `sudo pfctl -f /etc/pf.conf` By default, DNS resolvers return IPv6 (AAAA) records before IPv4 (A) records. Since our NAT only handles IPv4, Node.js applications inside containers will try IPv6 first and fail. The container image and runner are configured to prefer IPv4 via: + ``` NODE_OPTIONS=--dns-result-order=ipv4first ``` @@ -61,12 +64,12 @@ ifconfig bridge100 ## Troubleshooting -| Symptom | Cause | Fix | -|---------|-------|-----| -| `curl: (28) Connection timed out` | IP forwarding disabled | `sudo sysctl -w net.inet.ip.forwarding=1` | -| HTTP works, HTTPS times out | IPv6 DNS resolution | Add `NODE_OPTIONS=--dns-result-order=ipv4first` | -| `Could not resolve host` | DNS not forwarded | Check bridge100 exists, verify pfctl NAT rules | -| Container hangs after output | Missing `process.exit(0)` in agent-runner | Rebuild container image | +| Symptom | Cause | Fix | +| --------------------------------- | ----------------------------------------- | ----------------------------------------------- | +| `curl: (28) Connection timed out` | IP forwarding disabled | `sudo sysctl -w net.inet.ip.forwarding=1` | +| HTTP works, HTTPS times out | IPv6 DNS resolution | Add `NODE_OPTIONS=--dns-result-order=ipv4first` | +| `Could not resolve host` | DNS not forwarded | Check bridge100 exists, verify pfctl NAT rules | +| Container hangs after output | Missing `process.exit(0)` in agent-runner | Rebuild container image | ## How It Works diff --git a/docs/DEBUG_CHECKLIST.md b/docs/DEBUG_CHECKLIST.md index 811b7b0341d..88d5f08f12f 100644 --- a/docs/DEBUG_CHECKLIST.md +++ b/docs/DEBUG_CHECKLIST.md @@ -3,12 +3,15 @@ ## Known Issues (2026-02-08) ### 1. [FIXED] Resume branches from stale tree position + When agent teams spawns subagent CLI processes, they write to the same session JSONL. On subsequent `query()` resumes, the CLI reads the JSONL but may pick a stale branch tip (from before the subagent activity), causing the agent's response to land on a branch the host never receives a `result` for. **Fix**: pass `resumeSessionAt` with the last assistant message UUID to explicitly anchor each resume. ### 2. IDLE_TIMEOUT == CONTAINER_TIMEOUT (both 30 min) + Both timers fire at the same time, so containers always exit via hard SIGKILL (code 137) instead of graceful `_close` sentinel shutdown. The idle timeout should be shorter (e.g., 5 min) so containers wind down between messages, while container timeout stays at 30 min as a safety net for stuck agents. ### 3. Cursor advanced before agent succeeds + `processGroupMessages` advances `lastAgentTimestamp` before the agent runs. If the container times out, retries find no messages (cursor already past them). Messages are permanently lost on timeout. ## Quick Status Check diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 9214fa6e106..05dc582bbb5 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -47,7 +47,9 @@ When people contribute, they shouldn't add "Telegram support alongside WhatsApp. Skills we'd love contributors to build: ### Communication Channels + Skills to add or switch to different messaging platforms: + - `/add-telegram` - Add Telegram as an input channel - `/add-slack` - Add Slack as an input channel - `/add-discord` - Add Discord as an input channel @@ -55,10 +57,13 @@ Skills to add or switch to different messaging platforms: - `/convert-to-telegram` - Replace WhatsApp with Telegram entirely ### Container Runtime + The project uses Docker by default (cross-platform). For macOS users who prefer Apple Container: + - `/convert-to-apple-container` - Switch from Docker to Apple Container (macOS-only) ### Platform Support + - `/setup-linux` - Make the full setup work on Linux (depends on Docker conversion) - `/setup-windows` - Windows support via WSL2 + Docker @@ -69,6 +74,7 @@ The project uses Docker by default (cross-platform). For macOS users who prefer A personal Claude assistant accessible via WhatsApp, with minimal custom code. **Core components:** + - **Claude Agent SDK** as the core agent - **Containers** for isolated agent execution (Linux VMs) - **WhatsApp** as the primary I/O channel @@ -78,6 +84,7 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code. - **Browser automation** via agent-browser **Implementation approach:** + - Use existing tools (WhatsApp connector, Claude Agent SDK, MCP servers) - Minimal glue code - File-based systems where possible (CLAUDE.md for memory, folders for groups) @@ -87,22 +94,26 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code. ## Architecture Decisions ### Message Routing + - A router listens to WhatsApp and routes messages based on configuration - Only messages from registered groups are processed - Trigger: `@Andy` prefix (case insensitive), configurable via `ASSISTANT_NAME` env var - Unregistered groups are ignored completely ### Memory System + - **Per-group memory**: Each group has a folder with its own `CLAUDE.md` - **Global memory**: Root `CLAUDE.md` is read by all groups, but only writable from "main" (self-chat) - **Files**: Groups can create/read files in their folder and reference them - Agent runs in the group's folder, automatically inherits both CLAUDE.md files ### Session Management + - Each group maintains a conversation session (via Claude Agent SDK) - Sessions auto-compact when context gets too long, preserving critical information ### Container Isolation + - All agents run inside containers (lightweight Linux VMs) - Each agent invocation spawns a container with mounted directories - Containers provide filesystem isolation - agents can only see mounted paths @@ -110,6 +121,7 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code. - Browser automation via agent-browser with Chromium in the container ### Scheduled Tasks + - Users can ask Claude to schedule recurring or one-time tasks from any group - Tasks run as full agents in the context of the group that created them - Tasks have access to all tools including Bash (safe in container) @@ -120,12 +132,14 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code. - From other groups: can only manage that group's tasks ### Group Management + - New groups are added explicitly via the main channel - Groups are registered in SQLite (via the main channel or IPC `register_group` command) - Each group gets a dedicated folder under `groups/` - Groups can have additional directories mounted via `containerConfig` ### Main Channel Privileges + - Main channel is the admin/control group (typically self-chat) - Can write to global memory (`groups/CLAUDE.md`) - Can schedule tasks for any group @@ -137,11 +151,13 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code. ## Integration Points ### WhatsApp + - Using baileys library for WhatsApp Web connection - Messages stored in SQLite, polled by router - QR code authentication during setup ### Scheduler + - Built-in scheduler runs on the host, spawns containers for task execution - Custom `agentlite` MCP server (inside container) provides scheduling tools - Tools: `schedule_task`, `list_tasks`, `pause_task`, `resume_task`, `cancel_task`, `send_message` @@ -150,10 +166,12 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code. - Tasks execute Claude Agent SDK in containerized group context ### Web Access + - Built-in WebSearch and WebFetch tools - Standard Claude Agent SDK capabilities ### Browser Automation + - agent-browser CLI with Chromium in container - Snapshot-based interaction with element references (@e1, @e2, etc.) - Screenshots, PDFs, video recording @@ -164,17 +182,20 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code. ## Setup & Customization ### Philosophy + - Minimal configuration files - Setup and customization done via Claude Code - Users clone the repo and run Claude Code to configure - Each user gets a custom setup matching their exact needs ### Skills + - `/setup` - Install dependencies, authenticate WhatsApp, configure scheduler, start services - `/customize` - General-purpose skill for adding capabilities (new channels like Telegram, new integrations, behavior changes) - `/update` - Pull upstream changes, merge with customizations, run migrations ### Deployment + - Runs on local Mac via launchd - Single Node.js process handles everything diff --git a/docs/SDK_DEEP_DIVE.md b/docs/SDK_DEEP_DIVE.md index c07a3d4ee7a..e2204f3ecba 100644 --- a/docs/SDK_DEEP_DIVE.md +++ b/docs/SDK_DEEP_DIVE.md @@ -61,58 +61,63 @@ All complex logic — the agent loop, tool execution, background tasks, teammate Full `Options` type from sdk.d.ts (v0.2.76): -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `abortController` | `AbortController` | `new AbortController()` | Controller for cancelling operations | -| `additionalDirectories` | `string[]` | `[]` | Additional directories Claude can access | -| `agent` | `string` | `undefined` | Agent name for the main thread (like `--agent` CLI flag). Must be defined in `agents` option or settings. | -| `agents` | `Record` | `undefined` | Programmatically define subagents (not agent teams — no orchestration) | -| `agentProgressSummaries` | `boolean` | `false` | Enable periodic AI-generated progress summaries for running subagents (~30s). Emitted on `task_progress` events via `summary` field. | -| `allowDangerouslySkipPermissions` | `boolean` | `false` | Required when using `permissionMode: 'bypassPermissions'` | -| `allowedTools` | `string[]` | All tools | List of allowed tool names | -| `betas` | `SdkBeta[]` | `[]` | Beta features (e.g., `['context-1m-2025-08-07']` for 1M context) | -| `canUseTool` | `CanUseTool` | `undefined` | Custom permission function for tool usage | -| `continue` | `boolean` | `false` | Continue the most recent conversation | -| `cwd` | `string` | `process.cwd()` | Current working directory | -| `disallowedTools` | `string[]` | `[]` | List of disallowed tool names | -| `effort` | `'low' \| 'medium' \| 'high' \| 'max'` | `'high'` | Controls reasoning effort. Works with adaptive thinking to guide depth. `'max'` is Opus 4.6 only. | -| `enableFileCheckpointing` | `boolean` | `false` | Enable file change tracking for rewinding | -| `env` | `Dict` | `process.env` | Environment variables. Set `CLAUDE_AGENT_SDK_CLIENT_APP` for User-Agent identification. | -| `executable` | `'bun' \| 'deno' \| 'node'` | Auto-detected | JavaScript runtime | -| `executableArgs` | `string[]` | `[]` | Additional arguments to pass to the runtime executable | -| `extraArgs` | `Record` | `undefined` | Additional CLI args (keys without `--`, `null` for boolean flags) | -| `fallbackModel` | `string` | `undefined` | Model to use if primary fails | -| `forkSession` | `boolean` | `false` | When resuming, fork to a new session ID instead of continuing original | -| `hooks` | `Partial>` | `{}` | Hook callbacks for events (22 event types) | -| `includePartialMessages` | `boolean` | `false` | Include `SDKPartialAssistantMessage` (stream_event) during streaming — token-by-token deltas | -| `maxBudgetUsd` | `number` | `undefined` | Maximum budget in USD for the query | -| `maxThinkingTokens` | `number` | `undefined` | *Deprecated: use `thinking` instead.* On Opus 4.6, treated as on/off. | -| `maxTurns` | `number` | `undefined` | Maximum conversation turns | -| `mcpServers` | `Record` | `{}` | MCP server configurations | -| `model` | `string` | Default from CLI | Claude model to use | -| `onElicitation` | `OnElicitation` | `undefined` | Callback for MCP elicitation requests (auth, forms) not handled by hooks | -| `outputFormat` | `OutputFormat` | `undefined` | Structured output format (JSON schema) | -| `pathToClaudeCodeExecutable` | `string` | Uses built-in | Path to Claude Code executable | -| `permissionMode` | `PermissionMode` | `'default'` | Permission mode | -| `permissionPromptToolName` | `string` | `undefined` | MCP tool name to route permission prompts through | -| `persistSession` | `boolean` | `true` | When `false`, disables session persistence to disk. Sessions cannot be resumed later. Useful for ephemeral workflows. | -| `plugins` | `SdkPluginConfig[]` | `[]` | Load custom plugins from local paths | -| `promptSuggestions` | `boolean` | `false` | Emit `prompt_suggestion` after each turn with predicted next user prompt | -| `resume` | `string` | `undefined` | Session ID to resume | -| `resumeSessionAt` | `string` | `undefined` | Resume session at a specific message UUID | -| `sandbox` | `SandboxSettings` | `undefined` | Sandbox behavior configuration | -| `sessionId` | `string` | auto-generated UUID | Use a specific session ID. Cannot combine with `continue`/`resume` unless `forkSession` is set. | -| `settings` | `string \| object` | `undefined` | Additional settings (path to file or inline). Loaded as highest-priority "flag settings" layer. | -| `settingSources` | `SettingSource[]` | `[]` (none) | Which filesystem settings to load. Must include `'project'` to load CLAUDE.md | -| `systemPrompt` | `string \| { type: 'preset'; preset: 'claude_code'; append?: string }` | `undefined` | System prompt. Use preset to get Claude Code's prompt, with optional `append` | -| `thinking` | `ThinkingConfig` | `{ type: 'adaptive' }` for supported models | Controls thinking behavior: `{type:'adaptive'}` (Opus 4.6+), `{type:'enabled', budgetTokens:N}`, or `{type:'disabled'}` | -| `toolConfig` | `ToolConfig` | `undefined` | Per-tool configuration (e.g., `{askUserQuestion: {previewFormat:'html'}}`) | -| `tools` | `string[] \| { type: 'preset'; preset: 'claude_code' }` | `undefined` | Tool configuration | +| Property | Type | Default | Description | +| --------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `abortController` | `AbortController` | `new AbortController()` | Controller for cancelling operations | +| `additionalDirectories` | `string[]` | `[]` | Additional directories Claude can access | +| `agent` | `string` | `undefined` | Agent name for the main thread (like `--agent` CLI flag). Must be defined in `agents` option or settings. | +| `agents` | `Record` | `undefined` | Programmatically define subagents (not agent teams — no orchestration) | +| `agentProgressSummaries` | `boolean` | `false` | Enable periodic AI-generated progress summaries for running subagents (~30s). Emitted on `task_progress` events via `summary` field. | +| `allowDangerouslySkipPermissions` | `boolean` | `false` | Required when using `permissionMode: 'bypassPermissions'` | +| `allowedTools` | `string[]` | All tools | List of allowed tool names | +| `betas` | `SdkBeta[]` | `[]` | Beta features (e.g., `['context-1m-2025-08-07']` for 1M context) | +| `canUseTool` | `CanUseTool` | `undefined` | Custom permission function for tool usage | +| `continue` | `boolean` | `false` | Continue the most recent conversation | +| `cwd` | `string` | `process.cwd()` | Current working directory | +| `disallowedTools` | `string[]` | `[]` | List of disallowed tool names | +| `effort` | `'low' \| 'medium' \| 'high' \| 'max'` | `'high'` | Controls reasoning effort. Works with adaptive thinking to guide depth. `'max'` is Opus 4.6 only. | +| `enableFileCheckpointing` | `boolean` | `false` | Enable file change tracking for rewinding | +| `env` | `Dict` | `process.env` | Environment variables. Set `CLAUDE_AGENT_SDK_CLIENT_APP` for User-Agent identification. | +| `executable` | `'bun' \| 'deno' \| 'node'` | Auto-detected | JavaScript runtime | +| `executableArgs` | `string[]` | `[]` | Additional arguments to pass to the runtime executable | +| `extraArgs` | `Record` | `undefined` | Additional CLI args (keys without `--`, `null` for boolean flags) | +| `fallbackModel` | `string` | `undefined` | Model to use if primary fails | +| `forkSession` | `boolean` | `false` | When resuming, fork to a new session ID instead of continuing original | +| `hooks` | `Partial>` | `{}` | Hook callbacks for events (22 event types) | +| `includePartialMessages` | `boolean` | `false` | Include `SDKPartialAssistantMessage` (stream_event) during streaming — token-by-token deltas | +| `maxBudgetUsd` | `number` | `undefined` | Maximum budget in USD for the query | +| `maxThinkingTokens` | `number` | `undefined` | _Deprecated: use `thinking` instead._ On Opus 4.6, treated as on/off. | +| `maxTurns` | `number` | `undefined` | Maximum conversation turns | +| `mcpServers` | `Record` | `{}` | MCP server configurations | +| `model` | `string` | Default from CLI | Claude model to use | +| `onElicitation` | `OnElicitation` | `undefined` | Callback for MCP elicitation requests (auth, forms) not handled by hooks | +| `outputFormat` | `OutputFormat` | `undefined` | Structured output format (JSON schema) | +| `pathToClaudeCodeExecutable` | `string` | Uses built-in | Path to Claude Code executable | +| `permissionMode` | `PermissionMode` | `'default'` | Permission mode | +| `permissionPromptToolName` | `string` | `undefined` | MCP tool name to route permission prompts through | +| `persistSession` | `boolean` | `true` | When `false`, disables session persistence to disk. Sessions cannot be resumed later. Useful for ephemeral workflows. | +| `plugins` | `SdkPluginConfig[]` | `[]` | Load custom plugins from local paths | +| `promptSuggestions` | `boolean` | `false` | Emit `prompt_suggestion` after each turn with predicted next user prompt | +| `resume` | `string` | `undefined` | Session ID to resume | +| `resumeSessionAt` | `string` | `undefined` | Resume session at a specific message UUID | +| `sandbox` | `SandboxSettings` | `undefined` | Sandbox behavior configuration | +| `sessionId` | `string` | auto-generated UUID | Use a specific session ID. Cannot combine with `continue`/`resume` unless `forkSession` is set. | +| `settings` | `string \| object` | `undefined` | Additional settings (path to file or inline). Loaded as highest-priority "flag settings" layer. | +| `settingSources` | `SettingSource[]` | `[]` (none) | Which filesystem settings to load. Must include `'project'` to load CLAUDE.md | +| `systemPrompt` | `string \| { type: 'preset'; preset: 'claude_code'; append?: string }` | `undefined` | System prompt. Use preset to get Claude Code's prompt, with optional `append` | +| `thinking` | `ThinkingConfig` | `{ type: 'adaptive' }` for supported models | Controls thinking behavior: `{type:'adaptive'}` (Opus 4.6+), `{type:'enabled', budgetTokens:N}`, or `{type:'disabled'}` | +| `toolConfig` | `ToolConfig` | `undefined` | Per-tool configuration (e.g., `{askUserQuestion: {previewFormat:'html'}}`) | +| `tools` | `string[] \| { type: 'preset'; preset: 'claude_code' }` | `undefined` | Tool configuration | ### PermissionMode ```typescript -type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'dontAsk'; +type PermissionMode = + | 'default' + | 'acceptEdits' + | 'bypassPermissions' + | 'plan' + | 'dontAsk'; // 'dontAsk' — Don't prompt for permissions, deny if not pre-approved ``` @@ -133,27 +138,32 @@ Programmatic subagents (NOT agent teams — these are simpler, no inter-agent co ```typescript type AgentDefinition = { - description: string; // When to use this agent - prompt: string; // Agent's system prompt - tools?: string[]; // Allowed tools (inherits all if omitted) + description: string; // When to use this agent + prompt: string; // Agent's system prompt + tools?: string[]; // Allowed tools (inherits all if omitted) disallowedTools?: string[]; // Explicitly disallowed tools - model?: string; // Model alias ('sonnet', 'opus', 'haiku') or full ID. Omit to inherit. - mcpServers?: AgentMcpServerSpec[]; // MCP servers for this agent - skills?: string[]; // Skill names to preload - maxTurns?: number; // Max agentic turns before stopping - criticalSystemReminder_EXPERIMENTAL?: string; // Critical reminder in system prompt -} + model?: string; // Model alias ('sonnet', 'opus', 'haiku') or full ID. Omit to inherit. + mcpServers?: AgentMcpServerSpec[]; // MCP servers for this agent + skills?: string[]; // Skill names to preload + maxTurns?: number; // Max agentic turns before stopping + criticalSystemReminder_EXPERIMENTAL?: string; // Critical reminder in system prompt +}; ``` ### McpServerConfig ```typescript type McpServerConfig = - | { type?: 'stdio'; command: string; args?: string[]; env?: Record } + | { + type?: 'stdio'; + command: string; + args?: string[]; + env?: Record; + } | { type: 'sse'; url: string; headers?: Record } | { type: 'http'; url: string; headers?: Record } - | { type: 'sdk'; name: string; instance: McpServer } // in-process - | { type: 'claude_ai_proxy'; name: string; url: string } // claude.ai proxy + | { type: 'sdk'; name: string; instance: McpServer } // in-process + | { type: 'claude_ai_proxy'; name: string; url: string }; // claude.ai proxy ``` ### SdkBeta @@ -172,15 +182,19 @@ type CanUseTool = ( options: { signal: AbortSignal; suggestions?: PermissionUpdate[]; - blockedPath?: string; // File path that triggered the request - decisionReason?: string; // Why this permission request was triggered - toolUseID: string; // Unique ID for this tool call - agentID?: string; // Sub-agent ID if running in sub-agent context - } + blockedPath?: string; // File path that triggered the request + decisionReason?: string; // Why this permission request was triggered + toolUseID: string; // Unique ID for this tool call + agentID?: string; // Sub-agent ID if running in sub-agent context + }, ) => Promise; type PermissionResult = - | { behavior: 'allow'; updatedInput: ToolInput; updatedPermissions?: PermissionUpdate[] } + | { + behavior: 'allow'; + updatedInput: ToolInput; + updatedPermissions?: PermissionUpdate[]; + } | { behavior: 'deny'; message: string; interrupt?: boolean }; ``` @@ -188,34 +202,34 @@ type PermissionResult = `query()` can yield 21 message types (v0.2.76). The `SDKMessage` discriminated union from `sdk.d.ts`: -| Type | Subtype | Purpose | -|------|---------|---------| -| `assistant` | — | Claude's response (text + tool_use + thinking content blocks) | -| `user` | — | User message echo | -| `user` (replay) | — | Replayed user messages on session resume | -| `result` | `success` | Turn complete — result text, cost, usage, duration, model breakdown | -| `result` | `error_during_execution` | Error during execution | -| `result` | `error_max_turns` | Hit max turns limit | -| `result` | `error_max_budget_usd` | Hit budget limit | -| `result` | `error_max_structured_output_retries` | Structured output retries exhausted | -| `stream_event` | — | Token-by-token deltas wrapping `BetaRawMessageStreamEvent` (requires `includePartialMessages: true`) | -| `tool_progress` | — | Long-running tool heartbeat (tool_name, elapsed_time_seconds) | -| `tool_use_summary` | — | AI summary of preceding tool uses | -| `system` | `init` | Session initialized: version, model, tools, MCP servers, skills, plugins | -| `system` | `status` | Status change (e.g. `'compacting'`) | -| `system` | `task_started` | Subagent spawned (task_id, description, task_type, prompt) | -| `system` | `task_progress` | Subagent progress (usage, last_tool_name, summary) | -| `system` | `task_notification` | Subagent completed/failed/stopped (summary, output_file, usage) | -| `system` | `compact_boundary` | Context compaction occurred | -| `system` | `local_command_output` | Slash command output (e.g. /voice, /cost) | -| `system` | `hook_started` | Hook execution started | -| `system` | `hook_progress` | Hook progress output | -| `system` | `hook_response` | Hook completed | -| `system` | `files_persisted` | File checkpoints saved | -| `system` | `elicitation_complete` | MCP elicitation resolved | -| `auth_status` | — | Authentication state changes | -| `rate_limit_event` | — | Rate limit info (status, utilization, resets, overage) | -| `prompt_suggestion` | — | Predicted next user prompt (requires `promptSuggestions: true`) | +| Type | Subtype | Purpose | +| ------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `assistant` | — | Claude's response (text + tool_use + thinking content blocks) | +| `user` | — | User message echo | +| `user` (replay) | — | Replayed user messages on session resume | +| `result` | `success` | Turn complete — result text, cost, usage, duration, model breakdown | +| `result` | `error_during_execution` | Error during execution | +| `result` | `error_max_turns` | Hit max turns limit | +| `result` | `error_max_budget_usd` | Hit budget limit | +| `result` | `error_max_structured_output_retries` | Structured output retries exhausted | +| `stream_event` | — | Token-by-token deltas wrapping `BetaRawMessageStreamEvent` (requires `includePartialMessages: true`) | +| `tool_progress` | — | Long-running tool heartbeat (tool_name, elapsed_time_seconds) | +| `tool_use_summary` | — | AI summary of preceding tool uses | +| `system` | `init` | Session initialized: version, model, tools, MCP servers, skills, plugins | +| `system` | `status` | Status change (e.g. `'compacting'`) | +| `system` | `task_started` | Subagent spawned (task_id, description, task_type, prompt) | +| `system` | `task_progress` | Subagent progress (usage, last_tool_name, summary) | +| `system` | `task_notification` | Subagent completed/failed/stopped (summary, output_file, usage) | +| `system` | `compact_boundary` | Context compaction occurred | +| `system` | `local_command_output` | Slash command output (e.g. /voice, /cost) | +| `system` | `hook_started` | Hook execution started | +| `system` | `hook_progress` | Hook progress output | +| `system` | `hook_response` | Hook completed | +| `system` | `files_persisted` | File checkpoints saved | +| `system` | `elicitation_complete` | MCP elicitation resolved | +| `auth_status` | — | Authentication state changes | +| `rate_limit_event` | — | Rate limit info (status, utilization, resets, overage) | +| `prompt_suggestion` | — | Predicted next user prompt (requires `promptSuggestions: true`) | ### SDKTaskNotificationMessage (sdk.d.ts:1507) @@ -253,7 +267,11 @@ type SDKResultSuccess = { // Error: type SDKResultError = { type: 'result'; - subtype: 'error_during_execution' | 'error_max_turns' | 'error_max_budget_usd' | 'error_max_structured_output_retries'; + subtype: + | 'error_during_execution' + | 'error_max_turns' + | 'error_max_budget_usd' + | 'error_max_structured_output_retries'; errors: string[]; // ...shared fields }; @@ -320,17 +338,17 @@ Claude responded with text only — it decided it has completed the task. The AP ### Decision Table -| Condition | Action | Result Type | -|-----------|--------|-------------| -| Response has `tool_use` blocks | Execute tools, recurse into `EZ` | continues | -| Response has NO `tool_use` blocks | Run stop hooks, return | `success` | -| `turnCount > maxTurns` | Yield max_turns_reached | `error_max_turns` | -| `totalCost >= maxBudgetUsd` | Yield budget error | `error_max_budget_usd` | -| `abortController.signal.aborted` | Yield interrupted msg | depends on context | -| `stop_reason === "max_tokens"` (output) | Retry up to 3x with recovery prompt | continues | -| Stop hook `preventContinuation` | Return immediately | `success` | -| Stop hook blocking error | Feed error back, recurse | continues | -| Model fallback error | Retry with fallback model (one-time) | continues | +| Condition | Action | Result Type | +| --------------------------------------- | ------------------------------------ | ---------------------- | +| Response has `tool_use` blocks | Execute tools, recurse into `EZ` | continues | +| Response has NO `tool_use` blocks | Run stop hooks, return | `success` | +| `turnCount > maxTurns` | Yield max_turns_reached | `error_max_turns` | +| `totalCost >= maxBudgetUsd` | Yield budget error | `error_max_budget_usd` | +| `abortController.signal.aborted` | Yield interrupted msg | depends on context | +| `stop_reason === "max_tokens"` (output) | Retry up to 3x with recovery prompt | continues | +| Stop hook `preventContinuation` | Return immediately | `success` | +| Stop hook blocking error | Feed error back, recurse | continues | +| Model fallback error | Retry with fallback model (one-time) | continues | ## Subagent Execution Modes @@ -353,10 +371,10 @@ The team leader runs its normal EZ loop, which includes spawning teammates. When ```javascript while (true) { - // Check if no active teammates AND no running tasks → break - // Check for unread messages from teammates → re-inject as new prompt, restart EZ loop - // If stdin closed with active teammates → inject shutdown prompt - // Poll every 500ms + // Check if no active teammates AND no running tasks → break + // Check for unread messages from teammates → re-inject as new prompt, restart EZ loop + // If stdin closed with active teammates → inject shutdown prompt + // Poll every 500ms } ``` @@ -367,14 +385,14 @@ From the SDK consumer's perspective: you receive the initial `type: "result"`, b From sdk.mjs: ```javascript -QK = typeof X === "string" // isSingleUserTurn = true when prompt is a string +QK = typeof X === 'string'; // isSingleUserTurn = true when prompt is a string ``` When `isSingleUserTurn` is true and the first `result` message arrives: ```javascript if (this.isSingleUserTurn) { - this.transport.endInput(); // closes stdin to CLI + this.transport.endInput(); // closes stdin to CLI } ``` @@ -417,13 +435,14 @@ Instead of passing a string prompt (which sets `isSingleUserTurn = true`), pass ```typescript // Before (broken for agent teams): -query({ prompt: "do something" }) +query({ prompt: 'do something' }); // After (keeps CLI alive): -query({ prompt: asyncIterableOfMessages }) +query({ prompt: asyncIterableOfMessages }); ``` When prompt is an `AsyncIterable`: + - `isSingleUserTurn = false` - SDK does NOT close stdin after first result - CLI stays alive, continues processing @@ -498,15 +517,15 @@ for await (const msg of session.stream()) { /* events */ } ### Comparison Table -| Aspect | V1 | V2 | -|--------|----|----| -| `isSingleUserTurn` | `true` for string prompt | always `false` | -| Multi-turn | Requires managing `AsyncIterable` | Just call `send()`/`stream()` | -| stdin lifecycle | Auto-closes after first result | Stays open until `close()` | -| Agentic loop | Identical `EZ()` | Identical `EZ()` | -| Stop conditions | Same | Same | -| Session persistence | Must pass `resume` to new `query()` | Built-in via session object | -| API stability | Stable | Unstable preview (`unstable_v2_*` prefix) | +| Aspect | V1 | V2 | +| ------------------- | ----------------------------------- | ----------------------------------------- | +| `isSingleUserTurn` | `true` for string prompt | always `false` | +| Multi-turn | Requires managing `AsyncIterable` | Just call `send()`/`stream()` | +| stdin lifecycle | Auto-closes after first result | Stays open until `close()` | +| Agentic loop | Identical `EZ()` | Identical `EZ()` | +| Stop conditions | Same | Same | +| Session persistence | Must pass `resume` to new `query()` | Built-in via session object | +| API stability | Stable | Unstable preview (`unstable_v2_*` prefix) | **Key finding: Zero difference in turn behavior.** Both use the same CLI process, the same `EZ()` recursive generator, and the same decision logic. @@ -516,27 +535,27 @@ for await (const msg of session.stream()) { /* events */ } ```typescript type HookEvent = - | 'PreToolUse' // Before tool execution (can modify/block) - | 'PostToolUse' // After successful tool execution + | 'PreToolUse' // Before tool execution (can modify/block) + | 'PostToolUse' // After successful tool execution | 'PostToolUseFailure' // After failed tool execution - | 'PreCompact' // Before conversation compaction - | 'PostCompact' // After conversation compaction - | 'PermissionRequest' // Permission being requested - | 'UserPromptSubmit' // User prompt submitted - | 'SessionStart' // Session started (startup/resume/clear/compact) - | 'SessionEnd' // Session ended - | 'Stop' // Agent stopping - | 'SubagentStart' // Subagent spawned - | 'SubagentStop' // Subagent stopped - | 'TeammateIdle' // Teammate agent idle - | 'TaskCompleted' // Task finished - | 'Notification' // Agent wants to notify user - | 'Setup' // First-time setup - | 'Elicitation' // MCP elicitation request - | 'ElicitationResult' // MCP elicitation resolved - | 'ConfigChange' // Settings file changed - | 'WorktreeCreate' // Git worktree created - | 'WorktreeRemove' // Git worktree removed + | 'PreCompact' // Before conversation compaction + | 'PostCompact' // After conversation compaction + | 'PermissionRequest' // Permission being requested + | 'UserPromptSubmit' // User prompt submitted + | 'SessionStart' // Session started (startup/resume/clear/compact) + | 'SessionEnd' // Session ended + | 'Stop' // Agent stopping + | 'SubagentStart' // Subagent spawned + | 'SubagentStop' // Subagent stopped + | 'TeammateIdle' // Teammate agent idle + | 'TaskCompleted' // Task finished + | 'Notification' // Agent wants to notify user + | 'Setup' // First-time setup + | 'Elicitation' // MCP elicitation request + | 'ElicitationResult' // MCP elicitation resolved + | 'ConfigChange' // Settings file changed + | 'WorktreeCreate' // Git worktree created + | 'WorktreeRemove' // Git worktree removed | 'InstructionsLoaded'; // CLAUDE.md files loaded ``` @@ -544,14 +563,14 @@ type HookEvent = ```typescript interface HookCallbackMatcher { - matcher?: string; // Optional tool name matcher + matcher?: string; // Optional tool name matcher hooks: HookCallback[]; } type HookCallback = ( input: HookInput, toolUseID: string | undefined, - options: { signal: AbortSignal } + options: { signal: AbortSignal }, ) => Promise; ``` @@ -570,7 +589,11 @@ type SyncHookJSONOutput = { systemMessage?: string; reason?: string; hookSpecificOutput?: - | { hookEventName: 'PreToolUse'; permissionDecision?: 'allow' | 'deny' | 'ask'; updatedInput?: Record } + | { + hookEventName: 'PreToolUse'; + permissionDecision?: 'allow' | 'deny' | 'ask'; + updatedInput?: Record; + } | { hookEventName: 'UserPromptSubmit'; additionalContext?: string } | { hookEventName: 'SessionStart'; additionalContext?: string } | { hookEventName: 'PostToolUse'; additionalContext?: string }; @@ -604,10 +627,10 @@ The `Query` object extends `AsyncGenerator` with control metho ```typescript interface Query extends AsyncGenerator { // ── Execution control ────────────────────────────────────── - interrupt(): Promise; // Stop current execution - close(): void; // Kill query and all resources + interrupt(): Promise; // Stop current execution + close(): void; // Kill query and all resources streamInput(stream: AsyncIterable): Promise; // Inject more user messages - stopTask(taskId: string): Promise; // Stop a running subagent + stopTask(taskId: string): Promise; // Stop a running subagent // ── Live configuration ───────────────────────────────────── setPermissionMode(mode: PermissionMode): Promise; @@ -615,20 +638,25 @@ interface Query extends AsyncGenerator { setMaxThinkingTokens(max: number | null): Promise; // Deprecated: use thinking option // ── MCP server management ────────────────────────────────── - setMcpServers(servers: Record): Promise; + setMcpServers( + servers: Record, + ): Promise; reconnectMcpServer(serverName: string): Promise; toggleMcpServer(serverName: string, enabled: boolean): Promise; mcpServerStatus(): Promise; // ── Introspection ────────────────────────────────────────── initializationResult(): Promise; - supportedCommands(): Promise; // Available skills/slash commands + supportedCommands(): Promise; // Available skills/slash commands supportedModels(): Promise; - supportedAgents(): Promise; // Available subagents + supportedAgents(): Promise; // Available subagents accountInfo(): Promise; // ── File management ──────────────────────────────────────── - rewindFiles(userMessageId: string, options?: { dryRun?: boolean }): Promise; + rewindFiles( + userMessageId: string, + options?: { dryRun?: boolean }, + ): Promise; } ``` @@ -667,8 +695,11 @@ function tool( name: string, description: string, inputSchema: Schema, - handler: (args: z.infer>, extra: unknown) => Promise -): SdkMcpToolDefinition + handler: ( + args: z.infer>, + extra: unknown, + ) => Promise, +): SdkMcpToolDefinition; ``` ### createSdkMcpServer() @@ -680,7 +711,7 @@ function createSdkMcpServer(options: { name: string; version?: string; tools?: Array>; -}): McpSdkServerConfigWithInstance +}): McpSdkServerConfigWithInstance; ``` ## Session Management APIs @@ -721,17 +752,17 @@ forkSession(sessionId: string, options?: { ```typescript type SDKSessionInfo = { - sessionId: string; // UUID - summary: string; // Display title - lastModified: number; // ms since epoch - fileSize?: number; // JSONL file size (bytes) - customTitle?: string; // User-set title via /rename - firstPrompt?: string; // First meaningful user prompt - gitBranch?: string; // Git branch at end of session - cwd?: string; // Working directory - tag?: string; // User-set tag - createdAt?: number; // ms since epoch -} + sessionId: string; // UUID + summary: string; // Display title + lastModified: number; // ms since epoch + fileSize?: number; // JSONL file size (bytes) + customTitle?: string; // User-set title via /rename + firstPrompt?: string; // First meaningful user prompt + gitBranch?: string; // Git branch at end of session + cwd?: string; // Working directory + tag?: string; // User-set tag + createdAt?: number; // ms since epoch +}; ``` ### SessionMessage @@ -741,9 +772,9 @@ type SessionMessage = { type: 'user' | 'assistant'; uuid: string; session_id: string; - message: unknown; // Full API message (content blocks, tool_use, etc.) + message: unknown; // Full API message (content blocks, tool_use, etc.) parent_tool_use_id: null; -} +}; ``` These APIs are useful for **audit logging**: call `getSessionMessages()` from the host after each container run to read the full conversation without parsing JSONL yourself. The SDK handles chain resolution, compaction boundaries, and subagent merging internally. @@ -752,29 +783,29 @@ These APIs are useful for **audit logging**: call `getSessionMessages()` from th ### Key minified identifiers (sdk.mjs) -| Minified | Purpose | -|----------|---------| -| `s_` | V1 `query()` export | -| `e_` | `unstable_v2_createSession` | -| `Xx` | `unstable_v2_resumeSession` | -| `Qx` | `unstable_v2_prompt` | -| `U9` | V2 Session class (`send`/`stream`/`close`) | -| `XX` | ProcessTransport (spawns cli.js) | -| `$X` | Query class (JSON-line routing, async iterable) | -| `QX` | AsyncQueue (input stream buffer) | +| Minified | Purpose | +| -------- | ----------------------------------------------- | +| `s_` | V1 `query()` export | +| `e_` | `unstable_v2_createSession` | +| `Xx` | `unstable_v2_resumeSession` | +| `Qx` | `unstable_v2_prompt` | +| `U9` | V2 Session class (`send`/`stream`/`close`) | +| `XX` | ProcessTransport (spawns cli.js) | +| `$X` | Query class (JSON-line routing, async iterable) | +| `QX` | AsyncQueue (input stream buffer) | ### Key minified identifiers (cli.js) -| Minified | Purpose | -|----------|---------| -| `EZ` | Core recursive agentic loop (async generator) | -| `_t4` | Stop hook handler (runs when no tool_use blocks) | -| `PU1` | Streaming tool executor (parallel during API response) | -| `TP6` | Standard tool executor (after API response) | -| `GU1` | Individual tool executor | -| `lTq` | SDK session runner (calls EZ directly) | -| `bd1` | stdin reader (JSON-lines from transport) | -| `mW1` | Anthropic API streaming caller | +| Minified | Purpose | +| -------- | ------------------------------------------------------ | +| `EZ` | Core recursive agentic loop (async generator) | +| `_t4` | Stop hook handler (runs when no tool_use blocks) | +| `PU1` | Streaming tool executor (parallel during API response) | +| `TP6` | Standard tool executor (after API response) | +| `GU1` | Individual tool executor | +| `lTq` | SDK session runner (calls EZ directly) | +| `bd1` | stdin reader (JSON-lines from transport) | +| `mW1` | Anthropic API streaming caller | ## Key Files diff --git a/docs/SECURITY.md b/docs/SECURITY.md index c34e842b0b2..d024f07dcda 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -2,18 +2,19 @@ ## Trust Model -| Entity | Trust Level | Rationale | -|--------|-------------|-----------| -| Main group | Trusted | Private self-chat, admin control | -| Non-main groups | Untrusted | Other users may be malicious | -| Container agents | Sandboxed | Isolated execution environment | -| WhatsApp messages | User input | Potential prompt injection | +| Entity | Trust Level | Rationale | +| ----------------- | ----------- | -------------------------------- | +| Main group | Trusted | Private self-chat, admin control | +| Non-main groups | Untrusted | Other users may be malicious | +| Container agents | Sandboxed | Isolated execution environment | +| WhatsApp messages | User input | Potential prompt injection | ## Security Boundaries ### 1. Container Isolation (Primary Boundary) Agents execute in containers (lightweight Linux VMs), providing: + - **Process isolation** - Container processes cannot affect the host - **Filesystem isolation** - Only explicitly mounted directories are visible - **Non-root execution** - Runs as unprivileged `node` user (uid 1000) @@ -24,11 +25,13 @@ This is the primary security boundary. Rather than relying on application-level ### 2. Mount Security **External Allowlist** - Mount permissions stored at `~/.config/agentlite/mount-allowlist.json`, which is: + - Outside project root - Never mounted into containers - Cannot be modified by agents **Default Blocked Patterns:** + ``` .ssh, .gnupg, .aws, .azure, .gcloud, .kube, .boxlite, credentials, .env, .netrc, .npmrc, id_rsa, id_ed25519, @@ -36,6 +39,7 @@ private_key, .secret ``` **Protections:** + - Symlink resolution before validation (prevents traversal attacks) - Container path validation (rejects `..` and absolute paths) - `nonMainReadOnly` option forces read-only for non-main groups @@ -47,6 +51,7 @@ The main group's project root is mounted read-only. Writable paths the agent nee ### 3. Session Isolation Each group has isolated Claude sessions at `data/sessions/{group}/.claude/`: + - Groups cannot see other groups' conversation history - Session data includes full message history and file contents read - Prevents cross-group information disclosure @@ -55,20 +60,21 @@ Each group has isolated Claude sessions at `data/sessions/{group}/.claude/`: Messages and task operations are verified against group identity: -| Operation | Main Group | Non-Main Group | -|-----------|------------|----------------| -| Send message to own chat | ✓ | ✓ | -| Send message to other chats | ✓ | ✗ | -| Schedule task for self | ✓ | ✓ | -| Schedule task for others | ✓ | ✗ | -| View all tasks | ✓ | Own only | -| Manage other groups | ✓ | ✗ | +| Operation | Main Group | Non-Main Group | +| --------------------------- | ---------- | -------------- | +| Send message to own chat | ✓ | ✓ | +| Send message to other chats | ✓ | ✗ | +| Schedule task for self | ✓ | ✓ | +| Schedule task for others | ✓ | ✗ | +| View all tasks | ✓ | Own only | +| Manage other groups | ✓ | ✗ | ### 5. Credential Isolation (Credential Proxy) Real API credentials **never enter containers**. Instead, the host runs an HTTP credential proxy that injects authentication headers transparently. **How it works:** + 1. Host starts a credential proxy on `CREDENTIAL_PROXY_PORT` (default: 3001) 2. VMs receive `ANTHROPIC_BASE_URL=http://:` and `ANTHROPIC_API_KEY=placeholder` 3. The SDK sends API requests to the proxy with the placeholder key @@ -76,6 +82,7 @@ Real API credentials **never enter containers**. Instead, the host runs an HTTP 5. Agents cannot discover real credentials — not in environment, stdin, files, or `/proc` **NOT Mounted:** + - WhatsApp session (`store/auth/`) - host only - Mount allowlist - external, never mounted - Any credentials matching blocked patterns @@ -83,14 +90,14 @@ Real API credentials **never enter containers**. Instead, the host runs an HTTP ## Privilege Comparison -| Capability | Main Group | Non-Main Group | -|------------|------------|----------------| -| Project root access | `/workspace/project` (ro) | None | -| Group folder | `/workspace/group` (rw) | `/workspace/group` (rw) | -| Global memory | Implicit via project | `/workspace/global` (ro) | -| Additional mounts | Configurable | Read-only unless allowed | -| Network access | Unrestricted | Unrestricted | -| MCP tools | All | All | +| Capability | Main Group | Non-Main Group | +| ------------------- | ------------------------- | ------------------------ | +| Project root access | `/workspace/project` (ro) | None | +| Group folder | `/workspace/group` (rw) | `/workspace/group` (rw) | +| Global memory | Implicit via project | `/workspace/global` (ro) | +| Additional mounts | Configurable | Read-only unless allowed | +| Network access | Unrestricted | Unrestricted | +| MCP tools | All | All | ## Security Architecture Diagram diff --git a/docs/boxlite-migrate-plan.md b/docs/boxlite-migrate-plan.md index 18709bce9ec..5ab095ff1fc 100644 --- a/docs/boxlite-migrate-plan.md +++ b/docs/boxlite-migrate-plan.md @@ -5,6 +5,7 @@ AgentLite previously used Docker to run agent containers via `spawn('docker', ['run', ...])`. This document describes the migration to BoxLite (`@boxlite-ai/boxlite`), an embedded VM runtime library. **Why BoxLite?** + - **Embedded library** (no daemon) vs Docker's client-server model - **Hardware-level VM isolation** (KVM on Linux, Hypervisor.framework on macOS) vs container namespaces - **No root required** — macOS has built-in Hypervisor.framework; Linux only needs `/dev/kvm` group @@ -249,26 +250,27 @@ AFTER (BoxLite): Same protocol, different transport ## Summary of Changes -| Component | Before (Docker) | After (BoxLite) | -|-----------|-----------------|-----------------| -| Runtime check | `execSync('docker info')` | `JsBoxlite.withDefaultConfig()` | -| Orphan cleanup | `docker ps` + `docker stop` | `runtime.listInfo()` + `runtime.remove()` | -| Container create | `spawn('docker', ['run',...])` | `runtime.create(opts, name) -> JsBox` | -| Image setup | `Dockerfile` + `docker build` | `provision.sh` + `box.exec()` on first run | -| Input delivery | `container.stdin.write()` | `execution.stdin().writeString()` | +| Component | Before (Docker) | After (BoxLite) | +| ---------------- | ------------------------------------ | ------------------------------------------ | +| Runtime check | `execSync('docker info')` | `JsBoxlite.withDefaultConfig()` | +| Orphan cleanup | `docker ps` + `docker stop` | `runtime.listInfo()` + `runtime.remove()` | +| Container create | `spawn('docker', ['run',...])` | `runtime.create(opts, name) -> JsBox` | +| Image setup | `Dockerfile` + `docker build` | `provision.sh` + `box.exec()` on first run | +| Input delivery | `container.stdin.write()` | `execution.stdin().writeString()` | | Output streaming | `stdout.on('data')` + marker parsing | `stdout.next()` loop + same marker parsing | -| Stop/kill | `docker stop -t 1` | `box.stop()` | -| Process tracking | `ChildProcess` in GroupQueue | `boxName: string` in GroupQueue | -| Env injection | OneCLI mutates docker args | OneCLI -> parse `-e` flags -> box env | -| Host gateway | `--add-host=host.docker.internal` | Not needed (BoxLite handles networking) | -| Volume syntax | `-v host:container:ro` | `{ hostPath, guestPath, readOnly }` | -| User mapping | `--user ${uid}:${gid}` | `user: '${uid}:${gid}'` in box opts | +| Stop/kill | `docker stop -t 1` | `box.stop()` | +| Process tracking | `ChildProcess` in GroupQueue | `boxName: string` in GroupQueue | +| Env injection | OneCLI mutates docker args | OneCLI -> parse `-e` flags -> box env | +| Host gateway | `--add-host=host.docker.internal` | Not needed (BoxLite handles networking) | +| Volume syntax | `-v host:container:ro` | `{ hostPath, guestPath, readOnly }` | +| User mapping | `--user ${uid}:${gid}` | `user: '${uid}:${gid}'` in box opts | --- ## Provisioning (replaces Dockerfile) On first box creation, `container/provision.sh` runs inside the box to install: + - System packages: chromium, fonts (CJK, emoji), curl, git, X11 libs - Node.js globals: `agent-browser`, `@anthropic-ai/claude-code` - Directory structure: `/workspace/{group,global,extra,ipc/}` @@ -291,6 +293,7 @@ The agent-runner communicates via: ## Volume Mounts (unchanged logic) Per-group isolation model: + - **Main group**: Read-only project root (`/workspace/project`), writable group folder - **Other groups**: Own folder only + read-only global memory - **Per-group sessions**: Isolated `.claude/` in `data/sessions/{group}/` @@ -306,9 +309,14 @@ OneCLI gateway handles credential injection. Previously mutated Docker CLI args Now: extract env vars from the mutated args array and pass as `JsEnvVar[]` to `runtime.create()`. ```typescript -async function extractOnecliEnv(agentIdentifier?: string): Promise> { +async function extractOnecliEnv( + agentIdentifier?: string, +): Promise> { const tempArgs: string[] = []; - await onecli.applyContainerConfig(tempArgs, { addHostMapping: false, agent: agentIdentifier }); + await onecli.applyContainerConfig(tempArgs, { + addHostMapping: false, + agent: agentIdentifier, + }); const env: Record = {}; for (let i = 0; i < tempArgs.length; i++) { if (tempArgs[i] === '-e' && i + 1 < tempArgs.length) { @@ -326,6 +334,7 @@ async function extractOnecliEnv(agentIdentifier?: string): Promise .boxlite - `src/index.ts` -- updated imports diff --git a/docs/docker-sandboxes.md b/docs/docker-sandboxes.md index 9c2ca30e54c..004274f8dce 100644 --- a/docs/docker-sandboxes.md +++ b/docs/docker-sandboxes.md @@ -29,6 +29,7 @@ The sandbox provides a MITM proxy at `host.docker.internal:3128` that handles ne - For **WhatsApp**: a phone with WhatsApp installed Verify sandbox support: + ```bash docker sandbox version ``` @@ -57,6 +58,7 @@ docker sandbox network proxy shell-agentlite-workspace \ Telegram does not need proxy bypass. Enter the sandbox: + ```bash docker sandbox run shell-agentlite-workspace ``` @@ -221,6 +223,7 @@ npx tsx setup/index.ts --step register \ ``` **To find your chat ID:** Send any message to your bot, then: + ```bash curl -s --proxy $HTTPS_PROXY "https://api.telegram.org/bot/getUpdates" | python3 -m json.tool ``` @@ -296,6 +299,7 @@ Agent container → DinD bridge → Sandbox VM → host.docker.internal:3128 → ### Shared paths for DinD mounts Only the workspace directory is available for Docker-in-Docker bind mounts. Paths outside the workspace fail with "path not shared": + - `/dev/null` → replace with an empty file in the project dir - `/usr/local/share/ca-certificates/` → copy cert to project dir - `/home/agent/` → clone to workspace instead @@ -307,11 +311,13 @@ The workspace is mounted via virtiofs. Git's pack file handling can corrupt over ## Troubleshooting ### npm install fails with SELF_SIGNED_CERT_IN_CHAIN + ```bash npm config set strict-ssl false ``` ### Container build fails with proxy errors + ```bash docker build \ --build-arg http_proxy=$http_proxy \ @@ -320,19 +326,25 @@ docker build \ ``` ### Agent containers fail with "path not shared" + All bind-mounted paths must be under the workspace directory. Check: + - Is AgentLite cloned into the workspace? (not `/home/agent/`) - Is the CA cert copied to the project root? - Has the empty `.env` shadow file been created? ### Agent containers can't reach Anthropic API + Verify proxy env vars are forwarded to agent containers. Check container logs for `HTTP_PROXY=http://host.docker.internal:3128`. ### WhatsApp error 405 + The version fetch is returning a stale version. Make sure the proxy-aware `fetchWaVersionViaProxy` patch is applied — it fetches `sw.js` through `HttpsProxyAgent` and parses `client_revision`. ### WhatsApp "Connection failed" immediately + Proxy bypass not configured. From the **host**, run: + ```bash docker sandbox network proxy \ --bypass-host web.whatsapp.com \ @@ -341,17 +353,22 @@ docker sandbox network proxy \ ``` ### Telegram bot doesn't receive messages + 1. Check the grammy proxy patch is applied (look for `HttpsProxyAgent` in `src/channels/telegram.ts`) 2. Check Group Privacy is disabled in @BotFather if using in groups ### Git clone fails with "inflate: data stream error" + Clone to a non-workspace path first, then move: + ```bash cd ~ && git clone https://github.com/boxlite-ai/agentlite.git && mv agentlite /path/to/workspace/agentlite ``` ### WhatsApp QR code doesn't display + Run the auth command interactively inside the sandbox (not piped through `docker sandbox exec`): + ```bash docker sandbox run shell-agentlite-workspace # Then inside: diff --git a/docs/skills-as-branches.md b/docs/skills-as-branches.md index 8e684e180ed..3a7eb8e5179 100644 --- a/docs/skills-as-branches.md +++ b/docs/skills-as-branches.md @@ -6,12 +6,12 @@ This document covers **feature skills** — skills that add capabilities via git AgentLite has four types of skills overall. See [CONTRIBUTING.md](../CONTRIBUTING.md) for the full taxonomy: -| Type | Location | How it works | -|------|----------|-------------| -| **Feature** (this doc) | `.claude/skills/` + `skill/*` branch | SKILL.md has instructions; code lives on a branch, applied via `git merge` | -| **Utility** | `.claude/skills//` with code files | Self-contained tools; code in skill directory, copied into place on install | -| **Operational** | `.claude/skills/` on `main` | Instruction-only workflows (setup, debug, update) | -| **Container** | `container/skills/` | Loaded inside agent containers at runtime | +| Type | Location | How it works | +| ---------------------- | ---------------------------------------- | --------------------------------------------------------------------------- | +| **Feature** (this doc) | `.claude/skills/` + `skill/*` branch | SKILL.md has instructions; code lives on a branch, applied via `git merge` | +| **Utility** | `.claude/skills//` with code files | Self-contained tools; code in skill directory, copied into place on install | +| **Operational** | `.claude/skills/` on `main` | Instruction-only workflows (setup, debug, update) | +| **Container** | `container/skills/` | Loaded inside agent containers at runtime | --- @@ -39,11 +39,13 @@ Each skill branch contains all the code changes for that skill: new files, modif Skills are split into two categories: **Operational skills** (on `main`, always available): + - `/setup`, `/debug`, `/update-agentlite`, `/customize`, `/update-skills` - These are instruction-only SKILL.md files — no code changes, just workflows - Live in `.claude/skills/` on `main`, immediately available to every user **Feature skills** (in marketplace, installed on demand): + - `/add-discord`, `/add-telegram`, `/add-slack`, `/add-gmail`, etc. - Each has a SKILL.md with setup instructions and a corresponding `skill/*` branch with code - Live in the marketplace repo (`boxlite-ai/agentlite-skills`) @@ -161,6 +163,7 @@ done This requires no state — it uses git history to determine which skills were previously merged and whether they have new commits. This logic is available in two ways: + - Built into `/update-agentlite` — after merging main, optionally check for skill updates - Standalone `/update-skills` — check and merge skill updates independently @@ -203,6 +206,7 @@ A GitHub Action runs on every push to `main`: 5. If a skill fails (conflict, build error, test failure), open a GitHub issue for manual resolution **Why merge-forward instead of rebase:** + - No force-push — preserves history for users who already merged the skill - Users can re-merge a skill branch to pick up skill updates (bug fixes, improvements) - Git has proper common ancestors throughout the merge graph @@ -250,17 +254,20 @@ Users who previously applied skills via the `skills-engine/` system have skill c **For existing old-engine skills**, two migration paths: **Option A: Per-skill reapply (keep your fork)** + 1. For each old-engine skill: identify and revert the old changes, then merge the skill branch fresh 2. Claude assists with identifying what to revert and resolving any conflicts 3. Custom modifications (non-skill changes) are preserved **Option B: Fresh start (cleanest)** + 1. Create a new fork from upstream 2. Merge the skill branches you want 3. Manually re-apply your custom (non-skill) changes 4. Claude assists by diffing your old fork against the new one to identify custom changes In both cases: + - Delete the `.agentlite/` directory (no longer needed) - The `skills-engine/` code will be removed from upstream once all skills are migrated - `/update-skills` only tracks skills applied via branch merge — old-engine skills won't appear in update checks @@ -348,6 +355,7 @@ When a skill PR is reviewed and approved: 4. Add the skill's SKILL.md to the marketplace repo (`boxlite-ai/agentlite-skills`) This way: + - The contributor gets merge credit (their PR is merged) - They're added to CONTRIBUTORS.md automatically by the maintainer - The skill branch is created from their work @@ -444,6 +452,7 @@ A flavor is a curated fork of AgentLite — a combination of skills, custom chan During `/setup`, users are offered a choice of flavors before any configuration happens. The setup skill reads `flavors.yaml` from the repo (shipped with upstream, always up to date) and presents options: AskUserQuestion: "Start with a flavor or default AgentLite?" + - Default AgentLite - AgentLite for Sales — Gmail + Slack + CRM (maintained by alice) - AgentLite Minimal — Telegram-only, lightweight (maintained by bob) @@ -461,6 +470,7 @@ Then setup continues normally (dependencies, auth, container, service). **This choice is only offered on a fresh fork** — when the user's main matches or is close to upstream's main with no local commits. If `/setup` detects significant local changes (re-running setup on an existing install), it skips the flavor selection and goes straight to configuration. After installation, the user's fork has three remotes: + - `origin` — their fork (push customizations here) - `upstream` — `boxlite-ai/agentlite` (core updates) - `` — the flavor fork (flavor updates) @@ -506,20 +516,20 @@ Migration from the old skills engine to branches is complete. All feature skills ### Skill branches -| Branch | Base | Description | -|--------|------|-------------| -| `skill/whatsapp` | `main` | WhatsApp channel | -| `skill/telegram` | `main` | Telegram channel | -| `skill/slack` | `main` | Slack channel | -| `skill/discord` | `main` | Discord channel | -| `skill/gmail` | `main` | Gmail channel | -| `skill/voice-transcription` | `skill/whatsapp` | OpenAI Whisper voice transcription | -| `skill/image-vision` | `skill/whatsapp` | Image attachment processing | -| `skill/pdf-reader` | `skill/whatsapp` | PDF attachment reading | -| `skill/local-whisper` | `skill/voice-transcription` | Local whisper.cpp transcription | -| `skill/ollama-tool` | `main` | Ollama MCP server for local models | -| `skill/apple-container` | `main` | Apple Container runtime | -| `skill/reactions` | `main` | WhatsApp emoji reactions | +| Branch | Base | Description | +| --------------------------- | --------------------------- | ---------------------------------- | +| `skill/whatsapp` | `main` | WhatsApp channel | +| `skill/telegram` | `main` | Telegram channel | +| `skill/slack` | `main` | Slack channel | +| `skill/discord` | `main` | Discord channel | +| `skill/gmail` | `main` | Gmail channel | +| `skill/voice-transcription` | `skill/whatsapp` | OpenAI Whisper voice transcription | +| `skill/image-vision` | `skill/whatsapp` | Image attachment processing | +| `skill/pdf-reader` | `skill/whatsapp` | PDF attachment reading | +| `skill/local-whisper` | `skill/voice-transcription` | Local whisper.cpp transcription | +| `skill/ollama-tool` | `main` | Ollama MCP server for local models | +| `skill/apple-container` | `main` | Apple Container runtime | +| `skill/reactions` | `main` | WhatsApp emoji reactions | ### What was removed @@ -537,6 +547,7 @@ Operational skills (`setup`, `debug`, `update-agentlite`, `customize`, `update-s ### README Quick Start Before: + ```bash git clone https://github.com/qwibitai/AgentLite.git cd AgentLite @@ -544,6 +555,7 @@ claude ``` After: + ``` 1. Fork boxlite-ai/agentlite on GitHub 2. git clone https://github.com//agentlite.git @@ -611,6 +623,7 @@ Operational skills (`setup`, `debug`, `update-agentlite`, `customize`, `update-s The update skill gets simpler with the branch-based approach. The old skills engine required replaying all applied skills after merging core updates — that entire step disappears. Skill changes are already in the user's git history, so `git merge upstream/main` just works. **What stays the same:** + - Preflight (clean working tree, upstream remote) - Backup branch + tag - Preview (git log, git diff, file buckets) @@ -621,10 +634,12 @@ The update skill gets simpler with the branch-based approach. The old skills eng - Rollback instructions **What's removed:** + - Skill replay step (was needed by the old skills engine to re-apply skills after core update) - Re-running structured operations (npm deps, env vars — these are part of git history now) **What's added:** + - Optional step at the end: "Check for skill updates?" which runs the `/update-skills` logic - This checks whether any previously-merged skill branches have new commits (bug fixes, improvements to the skill itself — not just merge-forwards from main) @@ -642,6 +657,7 @@ Users only need to re-merge a skill branch if the skill itself was updated (not > We've simplified how skills work in AgentLite. Instead of a custom skills engine, skills are now git branches that you merge in. > > **What this means for you:** +> > - Applying a skill: `git fetch upstream skill/discord && git merge upstream/skill/discord` > - Updating core: `git fetch upstream main && git merge upstream/main` > - Checking for skill updates: `/update-skills` @@ -650,6 +666,7 @@ Users only need to re-merge a skill branch if the skill itself was updated (not > **We now recommend forking instead of cloning.** This gives you a remote to push your customizations to. > > **If you currently have a clone with local changes**, migrate to a fork: +> > 1. Fork `boxlite-ai/agentlite` on GitHub > 2. Run: > ``` @@ -668,6 +685,7 @@ Users only need to re-merge a skill branch if the skill itself was updated (not > **Contributing skills** > > To contribute a skill: +> > 1. Fork `boxlite-ai/agentlite` > 2. Branch from `main` and make your code changes > 3. Open a regular PR diff --git a/eslint.config.js b/eslint.config.js index fa257de3839..7d75d69821f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,7 +1,7 @@ -import globals from 'globals' -import pluginJs from '@eslint/js' -import tseslint from 'typescript-eslint' -import noCatchAll from 'eslint-plugin-no-catch-all' +import globals from 'globals'; +import pluginJs from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import noCatchAll from 'eslint-plugin-no-catch-all'; export default [ { ignores: ['node_modules/', 'dist/', 'container/', 'groups/'] }, @@ -29,4 +29,4 @@ export default [ '@typescript-eslint/no-explicit-any': 'warn', }, }, -] +]; diff --git a/groups/global/CLAUDE.md b/groups/global/CLAUDE.md index 18419694b08..6f117c10773 100644 --- a/groups/global/CLAUDE.md +++ b/groups/global/CLAUDE.md @@ -44,6 +44,7 @@ Files you create are saved in `/workspace/group/`. Use this for notes, research, The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions. When you learn something important: + - Create files for structured data (e.g., `customers.md`, `preferences.md`) - Split files larger than 500 lines into folders - Keep an index in your memory for the files you create @@ -55,6 +56,7 @@ Format messages based on the channel you're responding to. Check your group fold ### Slack channels (folder starts with `slack_`) Use Slack mrkdwn syntax. Run `/slack-formatting` for the full reference. Key rules: + - `*bold*` (single asterisks) - `_italic_` (underscores) - `` for links (NOT `[text](url)`) diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index be406162aa7..5671bc7430d 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -40,6 +40,7 @@ When working as a sub-agent or teammate, only use `send_message` if instructed t The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions. When you learn something important: + - Create files for structured data (e.g., `customers.md`, `preferences.md`) - Split files larger than 500 lines into folders - Keep an index in your memory for the files you create @@ -51,6 +52,7 @@ Format messages based on the channel. Check the group folder name prefix: ### Slack channels (folder starts with `slack_`) Use Slack mrkdwn syntax. Run `/slack-formatting` for the full reference. Key rules: + - `*bold*` (single asterisks) - `_italic_` (underscores) - `` for links (NOT `[text](url)`) @@ -82,12 +84,13 @@ This is the **main channel**, which has elevated privileges. Main has read-only access to the project and read-write access to its group folder: -| Container Path | Host Path | Access | -|----------------|-----------|--------| -| `/workspace/project` | Project root | read-only | -| `/workspace/group` | `groups/main/` | read-write | +| Container Path | Host Path | Access | +| -------------------- | -------------- | ---------- | +| `/workspace/project` | Project root | read-only | +| `/workspace/group` | `groups/main/` | read-write | Key paths inside the container: + - `/workspace/project/store/messages.db` - SQLite database - `/workspace/project/store/messages.db` (registered_groups table) - Group config - `/workspace/project/groups/` - All group folders @@ -152,6 +155,7 @@ Groups are registered in the SQLite `registered_groups` table: ``` Fields: + - **Key**: The chat JID (unique identifier — WhatsApp, Telegram, Slack, Discord, etc.) - **name**: Display name for the group - **folder**: Channel-prefixed folder name under `groups/` for this group's files and memory @@ -175,6 +179,7 @@ Fields: 5. Optionally create an initial `CLAUDE.md` for the group Folder naming convention — channel prefix with underscore separator: + - WhatsApp "Family Chat" → `whatsapp_family-chat` - Telegram "Dev Team" → `telegram_dev-team` - Discord "General" → `discord_general` @@ -234,6 +239,7 @@ If the user wants to set up an allowlist, edit `~/.config/agentlite/sender-allow ``` Notes: + - Your own messages (`is_from_me`) explicitly bypass the allowlist in trigger checks. Bot messages are filtered out by the database query before trigger evaluation, so they never reach the allowlist. - If the config file doesn't exist or is invalid, all senders are allowed (fail-open) - The config file is on the host at `~/.config/agentlite/sender-allowlist.json`, not inside the container @@ -260,6 +266,7 @@ You can read and write to `/workspace/project/groups/global/CLAUDE.md` for facts ## Scheduling for Other Groups When scheduling tasks for other groups, use the `target_group_jid` parameter with the group's JID from `registered_groups.json`: + - `schedule_task(prompt: "...", schedule_type: "cron", schedule_value: "0 9 * * 1", target_group_jid: "120363336345536173@g.us")` The task will run in that group's context with access to their files and memory. diff --git a/repo-tokens/README.md b/repo-tokens/README.md index f2eb0a68108..80c300f1d93 100644 --- a/repo-tokens/README.md +++ b/repo-tokens/README.md @@ -32,8 +32,8 @@ This badge gives some indication of how easy it will be to work with an agent on Repos using repo-tokens: -| Repo | Badge | -|------|-------| +| Repo | Badge | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------ | | [AgentLite](https://github.com/qwibitai/AgentLite) | ![tokens](https://raw.githubusercontent.com/qwibitai/AgentLite/main/repo-tokens/badge.svg) | ### Full workflow example @@ -88,23 +88,23 @@ The action replaces everything between the markers with the token count. ## Inputs -| Input | Default | Description | -|-------|---------|-------------| -| `include` | *required* | Glob patterns for files to count (space-separated) | -| `exclude` | `''` | Glob patterns to exclude (space-separated) | -| `context-window` | `200000` | Context window size for percentage calculation | -| `readme` | `README.md` | Path to README file | -| `encoding` | `cl100k_base` | Tiktoken encoding name | -| `marker` | `token-count` | HTML comment marker name | -| `badge-path` | `''` | Path to write SVG badge (empty = no SVG) | +| Input | Default | Description | +| ---------------- | ------------- | -------------------------------------------------- | +| `include` | _required_ | Glob patterns for files to count (space-separated) | +| `exclude` | `''` | Glob patterns to exclude (space-separated) | +| `context-window` | `200000` | Context window size for percentage calculation | +| `readme` | `README.md` | Path to README file | +| `encoding` | `cl100k_base` | Tiktoken encoding name | +| `marker` | `token-count` | HTML comment marker name | +| `badge-path` | `''` | Path to write SVG badge (empty = no SVG) | ## Outputs -| Output | Description | -|--------|-------------| -| `tokens` | Total token count (e.g., `34940`) | -| `percentage` | Percentage of context window (e.g., `17`) | -| `badge` | The formatted text that was inserted (e.g., `34.9k tokens · 17% of context window`) | +| Output | Description | +| ------------ | ----------------------------------------------------------------------------------- | +| `tokens` | Total token count (e.g., `34940`) | +| `percentage` | Percentage of context window (e.g., `17`) | +| `badge` | The formatted text that was inserted (e.g., `34.9k tokens · 17% of context window`) | ## How it works diff --git a/scripts/test-boxlite-e2e.ts b/scripts/test-boxlite-e2e.ts index c50ab07d180..ea17f8d7119 100644 --- a/scripts/test-boxlite-e2e.ts +++ b/scripts/test-boxlite-e2e.ts @@ -30,7 +30,10 @@ fs.mkdirSync(dataDir, { recursive: true }); // Create a minimal CLAUDE.md for the test group const claudeMd = path.join(testGroupDir, 'CLAUDE.md'); if (!fs.existsSync(claudeMd)) { - fs.writeFileSync(claudeMd, '# E2E Test Group\nThis is a test group for BoxLite e2e testing.\n'); + fs.writeFileSync( + claudeMd, + '# E2E Test Group\nThis is a test group for BoxLite e2e testing.\n', + ); } const testGroup: RegisteredGroup = { @@ -48,7 +51,10 @@ const testInput = { }; console.log('=== AgentLite BoxLite E2E Test ===\n'); -console.log('Image:', process.env.BOX_IMAGE || 'ghcr.io/boxlite-ai/agentlite-agent:latest'); +console.log( + 'Image:', + process.env.BOX_IMAGE || 'ghcr.io/boxlite-ai/agentlite-agent:latest', +); console.log('Prompt:', testInput.prompt); console.log(''); @@ -64,7 +70,9 @@ try { }, async (output: ContainerOutput) => { streamedOutputs.push(output); - console.log(`[stream] status=${output.status} result=${output.result?.slice(0, 200) || '(null)'}`); + console.log( + `[stream] status=${output.status} result=${output.result?.slice(0, 200) || '(null)'}`, + ); }, ); @@ -88,7 +96,9 @@ try { const hasResult = streamedOutputs.some((o) => o.result); if (!hasResult) { - console.warn('WARN: No result text in streamed outputs (agent may have responded with tool use only)'); + console.warn( + 'WARN: No result text in streamed outputs (agent may have responded with tool use only)', + ); } console.log('\nPASS: E2E test completed successfully'); diff --git a/scripts/test-host-reach.ts b/scripts/test-host-reach.ts index 4ebb982bfd1..10568ee9259 100644 --- a/scripts/test-host-reach.ts +++ b/scripts/test-host-reach.ts @@ -86,10 +86,7 @@ async function main() { await runInGuest('ip-route', 'ip route 2>&1 || route -n 2>&1'); await runInGuest('resolv', 'cat /etc/resolv.conf 2>&1'); // Install curl (proves outbound works) - await runInGuest( - 'install-curl', - 'apk add --no-cache curl 2>&1 | tail -3', - ); + await runInGuest('install-curl', 'apk add --no-cache curl 2>&1 | tail -3'); // Try candidate host addresses for (const addr of [ '192.168.127.254', // gvproxy host-loopback alias diff --git a/scripts/test-mcp-in-container.ts b/scripts/test-mcp-in-container.ts index fcd15784bcd..acff8a3287a 100644 --- a/scripts/test-mcp-in-container.ts +++ b/scripts/test-mcp-in-container.ts @@ -154,15 +154,13 @@ async function main() { "console.log('MCP_IMPORT_OK:' + typeof McpServer);", ].join('\n'), }, - [ - '#!/bin/bash', - 'set -e', - 'node /app/src/mcp/test/server.mjs', - ].join('\n'), + ['#!/bin/bash', 'set -e', 'node /app/src/mcp/test/server.mjs'].join('\n'), ); if (stdout.includes('MCP_IMPORT_OK:function')) { - pass('JS MCP server resolves @modelcontextprotocol/sdk via /app/node_modules (no symlink)'); + pass( + 'JS MCP server resolves @modelcontextprotocol/sdk via /app/node_modules (no symlink)', + ); } else { fail( 'JS MCP server', @@ -193,7 +191,9 @@ async function main() { ); if (stdout.includes('TS_MCP_OK:function:hello')) { - pass('TS MCP server runs with --experimental-transform-types at /app/src/mcp/'); + pass( + 'TS MCP server runs with --experimental-transform-types at /app/src/mcp/', + ); } else { fail( 'TS MCP server', @@ -223,7 +223,10 @@ async function main() { ].join('\n'), ); - if (stdout.includes('IMPORT_FAILED') && !stdout.includes('SHOULD_NOT_REACH')) { + if ( + stdout.includes('IMPORT_FAILED') && + !stdout.includes('SHOULD_NOT_REACH') + ) { pass('Control: outside /app/ tree, import correctly fails'); } else { fail('Control test', `stdout=${stdout.trim()}`); diff --git a/scripts/test-sdk.ts b/scripts/test-sdk.ts index 63d22b68a80..365c46dcdc4 100644 --- a/scripts/test-sdk.ts +++ b/scripts/test-sdk.ts @@ -5,7 +5,11 @@ const agent_lite = new AgentLite(); await agent_lite.start(); -await agent_lite.registerChannel(new TelegramChannel({ token: '8661866633:AAFcPI-aKxvLk5PF96ozzT4JuDEFw51WN3k' })); +await agent_lite.registerChannel( + new TelegramChannel({ + token: '8661866633:AAFcPI-aKxvLk5PF96ozzT4JuDEFw51WN3k', + }), +); agent_lite.registerGroup('tg:7123844036', { name: 'Main', diff --git a/setup/environment.test.ts b/setup/environment.test.ts index deda62f1f8f..e88b9a2fd7c 100644 --- a/setup/environment.test.ts +++ b/setup/environment.test.ts @@ -118,4 +118,3 @@ describe('channel auth detection', () => { expect(hasAuth('/tmp/nonexistent_auth_dir_xyz')).toBe(false); }); }); - diff --git a/setup/groups.ts b/setup/groups.ts index 66970295e4a..499b15d01ba 100644 --- a/setup/groups.ts +++ b/setup/groups.ts @@ -191,7 +191,11 @@ sock.ev.on('connection.update', async (update) => { syncOk = output.includes('SYNCED:'); logger.info({ output: output.trim() }, 'Sync output'); } finally { - try { fs.unlinkSync(tmpScript); } catch { /* ignore cleanup errors */ } + try { + fs.unlinkSync(tmpScript); + } catch { + /* ignore cleanup errors */ + } } } catch (err) { logger.error({ err }, 'Sync failed'); diff --git a/setup/service.ts b/setup/service.ts index 48aa2e417ac..dd774d339d1 100644 --- a/setup/service.ts +++ b/setup/service.ts @@ -174,7 +174,6 @@ function killOrphanedProcesses(projectRoot: string): void { } } - function setupSystemd( projectRoot: string, nodePath: string, diff --git a/setup/verify.ts b/setup/verify.ts index ecae0ca70cd..784d7801976 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -38,7 +38,9 @@ export async function run(_args: string[]): Promise { const output = execSync('launchctl list', { encoding: 'utf-8' }); if (output.includes('com.agentlite')) { // Check if it has a PID (actually running) - const line = output.split('\n').find((l) => l.includes('com.agentlite')); + const line = output + .split('\n') + .find((l) => l.includes('com.agentlite')); if (line) { const pidField = line.trim().split(/\s+/)[0]; service = pidField !== '-' && pidField ? 'running' : 'stopped'; @@ -97,7 +99,11 @@ export async function run(_args: string[]): Promise { const envFile = path.join(projectRoot, '.env'); if (fs.existsSync(envFile)) { const envContent = fs.readFileSync(envFile, 'utf-8'); - if (/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ONECLI_URL)=/m.test(envContent)) { + if ( + /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ONECLI_URL)=/m.test( + envContent, + ) + ) { credentials = 'configured'; } } From c681edb5bd264a4ede61092c27ed7d127e433aa7 Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sat, 25 Apr 2026 06:20:21 +0800 Subject: [PATCH 14/21] Use expected usage summary query args binding --- src/agent/usage-actions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agent/usage-actions.ts b/src/agent/usage-actions.ts index ea78f9fb924..85fc33f10e7 100644 --- a/src/agent/usage-actions.ts +++ b/src/agent/usage-actions.ts @@ -25,10 +25,10 @@ export function registerUsageActions(agent: Agent, db: AgentDb): void { ), }, async (args, ctx) => { - const effectiveFilters = ctx.isMain + const queryArgs = ctx.isMain ? args : { ...args, group_jid: ctx.sourceGroup }; - return db.getTokenUsageSummary(effectiveFilters); + return db.getTokenUsageSummary(queryArgs); }, ); } From 184c15cfcd9355eaea0e8a48e434a5aeb74fe63d Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sat, 25 Apr 2026 07:05:08 +0800 Subject: [PATCH 15/21] =?UTF-8?q?fix:=20final=20fixes=20for=20token=20usag?= =?UTF-8?q?e=20tracking=20=E2=80=94=20scope=20+=20model=20attribution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- container/agent-runner/src/agent-backend.ts | 8 +------- container/agent-runner/src/index.test.ts | 10 +++++----- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/container/agent-runner/src/agent-backend.ts b/container/agent-runner/src/agent-backend.ts index b91bd0e9954..944bc47d78f 100644 --- a/container/agent-runner/src/agent-backend.ts +++ b/container/agent-runner/src/agent-backend.ts @@ -305,15 +305,9 @@ export function waitForIpcMessage( } export function resolveUsageModel( - currentModel: string | undefined, + _currentModel: string | undefined, modelUsage: SDKResultMessage['modelUsage'], ): string | null { - if (currentModel) { - return Object.prototype.hasOwnProperty.call(modelUsage, currentModel) - ? currentModel - : null; - } - const models = Object.entries(modelUsage); if (models.length === 1) { return models[0]![0]; diff --git a/container/agent-runner/src/index.test.ts b/container/agent-runner/src/index.test.ts index 017cc21a15b..279ec10d0b8 100644 --- a/container/agent-runner/src/index.test.ts +++ b/container/agent-runner/src/index.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { resolveUsageModel } from './index.js'; describe('resolveUsageModel', () => { - it('returns the current model when it is present in modelUsage', () => { + it('returns the only model in modelUsage', () => { expect( resolveUsageModel('claude-opus-4-6', { 'claude-opus-4-6': { @@ -16,7 +16,7 @@ describe('resolveUsageModel', () => { ).toBe('claude-opus-4-6'); }); - it('returns null when currentModel is not present in modelUsage', () => { + it('does not fall back to currentModel when it is not present in modelUsage', () => { expect( resolveUsageModel('claude-opus-4-6', { 'claude-haiku-4-5': { @@ -32,12 +32,12 @@ describe('resolveUsageModel', () => { cacheCreationInputTokens: 0, }, }), - ).toBeNull(); + ).toBe('claude-sonnet-4-6'); }); - it('falls back to the highest-token model when currentModel is unavailable', () => { + it('returns the highest-token model when multiple models are present', () => { expect( - resolveUsageModel(undefined, { + resolveUsageModel('claude-haiku-4-5', { 'claude-haiku-4-5': { inputTokens: 10, outputTokens: 10, From 90f1559cfb68f5d0c952afb2a6bb4a13525ae6dd Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sat, 25 Apr 2026 07:11:32 +0800 Subject: [PATCH 16/21] fix: scope usage_get_summary to sourceGroup + fix resolveUsageModel fallback + add tests --- container/agent-runner/src/agent-backend.ts | 6 +++++- container/agent-runner/src/index.test.ts | 4 ++-- src/agent/usage-actions.test.ts | 2 +- src/agent/usage-actions.ts | 5 ++--- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/container/agent-runner/src/agent-backend.ts b/container/agent-runner/src/agent-backend.ts index 944bc47d78f..594f7cdebdc 100644 --- a/container/agent-runner/src/agent-backend.ts +++ b/container/agent-runner/src/agent-backend.ts @@ -305,9 +305,13 @@ export function waitForIpcMessage( } export function resolveUsageModel( - _currentModel: string | undefined, + currentModel: string | undefined, modelUsage: SDKResultMessage['modelUsage'], ): string | null { + if (currentModel && Object.hasOwn(modelUsage, currentModel)) { + return currentModel; + } + const models = Object.entries(modelUsage); if (models.length === 1) { return models[0]![0]; diff --git a/container/agent-runner/src/index.test.ts b/container/agent-runner/src/index.test.ts index 279ec10d0b8..d453850aa5a 100644 --- a/container/agent-runner/src/index.test.ts +++ b/container/agent-runner/src/index.test.ts @@ -35,7 +35,7 @@ describe('resolveUsageModel', () => { ).toBe('claude-sonnet-4-6'); }); - it('returns the highest-token model when multiple models are present', () => { + it('returns currentModel when it is present in modelUsage', () => { expect( resolveUsageModel('claude-haiku-4-5', { 'claude-haiku-4-5': { @@ -51,7 +51,7 @@ describe('resolveUsageModel', () => { cacheCreationInputTokens: 0, }, }), - ).toBe('claude-sonnet-4-6'); + ).toBe('claude-haiku-4-5'); }); it('returns null when no usage model is available', () => { diff --git a/src/agent/usage-actions.test.ts b/src/agent/usage-actions.test.ts index 5f31881f3e0..8cac2aec31b 100644 --- a/src/agent/usage-actions.test.ts +++ b/src/agent/usage-actions.test.ts @@ -267,7 +267,7 @@ describe('usage_get_summary action', () => { expect(opus?.cost_usd).toBeCloseTo(0.01575); }); - it('scopes results to sourceGroup for non-main callers', async () => { + it('non-main caller sees only its own group data', async () => { agent.db.recordTokenUsage({ group_jid: 'test-group', session_id: 'session-local', diff --git a/src/agent/usage-actions.ts b/src/agent/usage-actions.ts index 85fc33f10e7..76db32c7b25 100644 --- a/src/agent/usage-actions.ts +++ b/src/agent/usage-actions.ts @@ -25,9 +25,8 @@ export function registerUsageActions(agent: Agent, db: AgentDb): void { ), }, async (args, ctx) => { - const queryArgs = ctx.isMain - ? args - : { ...args, group_jid: ctx.sourceGroup }; + const effectiveGroupJid = ctx.isMain ? args.group_jid : ctx.sourceGroup; + const queryArgs = { ...args, group_jid: effectiveGroupJid }; return db.getTokenUsageSummary(queryArgs); }, ); From 2149b4efd807a5db27ecc9a150b779351233e1ff Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sat, 25 Apr 2026 07:17:21 +0800 Subject: [PATCH 17/21] fix: scope usage_get_summary to sourceGroup, remove bad resolveUsageModel fallback, add missing tests --- src/agent/usage-actions.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/agent/usage-actions.ts b/src/agent/usage-actions.ts index 76db32c7b25..85fc33f10e7 100644 --- a/src/agent/usage-actions.ts +++ b/src/agent/usage-actions.ts @@ -25,8 +25,9 @@ export function registerUsageActions(agent: Agent, db: AgentDb): void { ), }, async (args, ctx) => { - const effectiveGroupJid = ctx.isMain ? args.group_jid : ctx.sourceGroup; - const queryArgs = { ...args, group_jid: effectiveGroupJid }; + const queryArgs = ctx.isMain + ? args + : { ...args, group_jid: ctx.sourceGroup }; return db.getTokenUsageSummary(queryArgs); }, ); From 4e8124f419a1c814946b7eeda58b172c03d02857 Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sat, 25 Apr 2026 07:34:18 +0800 Subject: [PATCH 18/21] fix: BLOCKER 1 sourceGroup scoping + BLOCKER 2 resolveUsageModel fallback + tests --- container/agent-runner/src/index.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/container/agent-runner/src/index.test.ts b/container/agent-runner/src/index.test.ts index d453850aa5a..8385d074b7c 100644 --- a/container/agent-runner/src/index.test.ts +++ b/container/agent-runner/src/index.test.ts @@ -57,4 +57,8 @@ describe('resolveUsageModel', () => { it('returns null when no usage model is available', () => { expect(resolveUsageModel(undefined, {})).toBeNull(); }); + + it('returns null when currentModel is not present and modelUsage is empty', () => { + expect(resolveUsageModel('claude-opus-4-6', {})).toBeNull(); + }); }); From db73290edaf2b47ea42ad56584fc94479e009bb1 Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sat, 25 Apr 2026 07:53:10 +0800 Subject: [PATCH 19/21] fix: resolve usage model by highest token usage --- container/agent-runner/src/agent-backend.ts | 4 ---- container/agent-runner/src/index.test.ts | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/container/agent-runner/src/agent-backend.ts b/container/agent-runner/src/agent-backend.ts index 594f7cdebdc..8fe09bf4045 100644 --- a/container/agent-runner/src/agent-backend.ts +++ b/container/agent-runner/src/agent-backend.ts @@ -308,10 +308,6 @@ export function resolveUsageModel( currentModel: string | undefined, modelUsage: SDKResultMessage['modelUsage'], ): string | null { - if (currentModel && Object.hasOwn(modelUsage, currentModel)) { - return currentModel; - } - const models = Object.entries(modelUsage); if (models.length === 1) { return models[0]![0]; diff --git a/container/agent-runner/src/index.test.ts b/container/agent-runner/src/index.test.ts index 8385d074b7c..867299b4e2b 100644 --- a/container/agent-runner/src/index.test.ts +++ b/container/agent-runner/src/index.test.ts @@ -35,7 +35,7 @@ describe('resolveUsageModel', () => { ).toBe('claude-sonnet-4-6'); }); - it('returns currentModel when it is present in modelUsage', () => { + it('returns the highest usage model when currentModel is present in modelUsage', () => { expect( resolveUsageModel('claude-haiku-4-5', { 'claude-haiku-4-5': { @@ -51,7 +51,7 @@ describe('resolveUsageModel', () => { cacheCreationInputTokens: 0, }, }), - ).toBe('claude-haiku-4-5'); + ).toBe('claude-sonnet-4-6'); }); it('returns null when no usage model is available', () => { From 83cc577fc0a70c7e735936b6c8f68e4a91c631d1 Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sat, 25 Apr 2026 07:57:14 +0800 Subject: [PATCH 20/21] test: clarify usage source group scope --- src/agent/usage-actions.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agent/usage-actions.test.ts b/src/agent/usage-actions.test.ts index 8cac2aec31b..5f31881f3e0 100644 --- a/src/agent/usage-actions.test.ts +++ b/src/agent/usage-actions.test.ts @@ -267,7 +267,7 @@ describe('usage_get_summary action', () => { expect(opus?.cost_usd).toBeCloseTo(0.01575); }); - it('non-main caller sees only its own group data', async () => { + it('scopes results to sourceGroup for non-main callers', async () => { agent.db.recordTokenUsage({ group_jid: 'test-group', session_id: 'session-local', From 222df99cab8497df62694b6bd1d1022a08c84068 Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sun, 26 Apr 2026 13:38:00 +0800 Subject: [PATCH 21/21] Fix token usage scoping and model attribution --- container/agent-runner/src/agent-backend.ts | 73 ++++++++---------- container/agent-runner/src/index.test.ts | 83 +++++++++++++++++++-- container/agent-runner/src/index.ts | 6 +- src/agent/usage-actions.test.ts | 24 +++++- src/agent/usage-actions.ts | 38 ++++++++-- src/container-runner.ts | 1 + src/db.ts | 10 +++ 7 files changed, 178 insertions(+), 57 deletions(-) diff --git a/container/agent-runner/src/agent-backend.ts b/container/agent-runner/src/agent-backend.ts index 8fe09bf4045..0d479439205 100644 --- a/container/agent-runner/src/agent-backend.ts +++ b/container/agent-runner/src/agent-backend.ts @@ -113,6 +113,7 @@ interface RuntimeTokenUsageOutput { total_tokens: number; cache_read_tokens: number; cache_write_tokens: number; + cost_usd?: number | null; latency_ms: number; ts: number; }; @@ -305,59 +306,51 @@ export function waitForIpcMessage( } export function resolveUsageModel( - currentModel: string | undefined, + _currentModel: string | undefined, modelUsage: SDKResultMessage['modelUsage'], ): string | null { const models = Object.entries(modelUsage); if (models.length === 1) { return models[0]![0]; } - if (models.length === 0) { - return null; - } - return models.slice().sort((a, b) => { - const totalA = a[1].inputTokens + a[1].outputTokens; - const totalB = b[1].inputTokens + b[1].outputTokens; - if (totalA !== totalB) return totalB - totalA; - return a[0].localeCompare(b[0]); - })[0]![0]; + return null; } -function captureTokenUsageSummary(params: { +export function captureTokenUsageSummaries(params: { groupJid: string; sessionId: string | undefined; currentModel: string | undefined; queryStartedAt: number; message: SDKResultMessage; -}): RuntimeTokenUsageOutput['usage'] | null { - const usage = params.message.usage; - const promptTokens = usage.input_tokens ?? 0; - const completionTokens = usage.output_tokens ?? 0; - const cacheReadTokens = usage.cache_read_input_tokens ?? 0; - const cacheWriteTokens = usage.cache_creation_input_tokens ?? 0; +}): Array { const ts = Date.now(); - const model = resolveUsageModel( - params.currentModel, - params.message.modelUsage, + const latencyMs = ts - params.queryStartedAt; + + return Object.entries(params.message.modelUsage).map( + ([model, modelStats]) => { + const cacheReadTokens = modelStats.cacheReadInputTokens ?? 0; + const cacheWriteTokens = modelStats.cacheCreationInputTokens ?? 0; + + return { + group_jid: params.groupJid, + session_id: params.sessionId ?? null, + model, + prompt_tokens: modelStats.inputTokens, + completion_tokens: modelStats.outputTokens, + total_tokens: + modelStats.inputTokens + + modelStats.outputTokens + + cacheReadTokens + + cacheWriteTokens, + cache_read_tokens: cacheReadTokens, + cache_write_tokens: cacheWriteTokens, + cost_usd: modelStats.costUSD, + latency_ms: latencyMs, + ts, + }; + }, ); - - if (!model) { - return null; - } - - return { - group_jid: params.groupJid, - session_id: params.sessionId ?? null, - model, - prompt_tokens: promptTokens, - completion_tokens: completionTokens, - total_tokens: promptTokens + completionTokens, - cache_read_tokens: cacheReadTokens, - cache_write_tokens: cacheWriteTokens, - latency_ms: ts - params.queryStartedAt, - ts, - }; } function getSessionSummary( @@ -870,17 +863,17 @@ class ClaudeCodeQueryRunner< // ── Backward-compat: emit result for host message delivery ─ if (message.type === 'result') { resultCount++; - const usageSummary = captureTokenUsageSummary({ + const usageSummaries = captureTokenUsageSummaries({ groupJid: containerInput.chatJid, sessionId: newSessionId ?? sessionId, currentModel, queryStartedAt, message, }); - if (usageSummary) { + for (const usage of usageSummaries) { this.deps.writeOutput({ type: 'token_usage', - usage: usageSummary, + usage, }); } diff --git a/container/agent-runner/src/index.test.ts b/container/agent-runner/src/index.test.ts index 867299b4e2b..cc7a378d9a3 100644 --- a/container/agent-runner/src/index.test.ts +++ b/container/agent-runner/src/index.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; -import { resolveUsageModel } from './index.js'; +import { captureTokenUsageSummaries, resolveUsageModel } from './index.js'; describe('resolveUsageModel', () => { it('returns the only model in modelUsage', () => { @@ -16,7 +16,7 @@ describe('resolveUsageModel', () => { ).toBe('claude-opus-4-6'); }); - it('does not fall back to currentModel when it is not present in modelUsage', () => { + it('returns null when currentModel is not present and modelUsage has multiple models', () => { expect( resolveUsageModel('claude-opus-4-6', { 'claude-haiku-4-5': { @@ -32,10 +32,10 @@ describe('resolveUsageModel', () => { cacheCreationInputTokens: 0, }, }), - ).toBe('claude-sonnet-4-6'); + ).toBeNull(); }); - it('returns the highest usage model when currentModel is present in modelUsage', () => { + it('returns null for multi-model usage so callers can record each model', () => { expect( resolveUsageModel('claude-haiku-4-5', { 'claude-haiku-4-5': { @@ -51,7 +51,7 @@ describe('resolveUsageModel', () => { cacheCreationInputTokens: 0, }, }), - ).toBe('claude-sonnet-4-6'); + ).toBeNull(); }); it('returns null when no usage model is available', () => { @@ -62,3 +62,74 @@ describe('resolveUsageModel', () => { expect(resolveUsageModel('claude-opus-4-6', {})).toBeNull(); }); }); + +describe('captureTokenUsageSummaries', () => { + it('returns one usage row per modelUsage entry with per-model costs', () => { + vi.useFakeTimers(); + vi.setSystemTime(1_710_000_000_500); + + const summaries = captureTokenUsageSummaries({ + groupJid: 'group-1@g.us', + sessionId: 'session-a', + currentModel: 'claude-opus-4-6', + queryStartedAt: 1_710_000_000_000, + message: { + type: 'result', + subtype: 'success', + usage: { + input_tokens: 600, + output_tokens: 300, + cache_read_input_tokens: 30, + cache_creation_input_tokens: 15, + }, + modelUsage: { + 'claude-haiku-4-5': { + inputTokens: 100, + outputTokens: 40, + cacheReadInputTokens: 5, + cacheCreationInputTokens: 2, + costUSD: 0.0002, + }, + 'claude-sonnet-4-6': { + inputTokens: 500, + outputTokens: 260, + cacheReadInputTokens: 25, + cacheCreationInputTokens: 13, + costUSD: 0.006, + }, + }, + } as any, + }); + + expect(summaries).toEqual([ + { + group_jid: 'group-1@g.us', + session_id: 'session-a', + model: 'claude-haiku-4-5', + prompt_tokens: 100, + completion_tokens: 40, + total_tokens: 147, + cache_read_tokens: 5, + cache_write_tokens: 2, + cost_usd: 0.0002, + latency_ms: 500, + ts: 1_710_000_000_500, + }, + { + group_jid: 'group-1@g.us', + session_id: 'session-a', + model: 'claude-sonnet-4-6', + prompt_tokens: 500, + completion_tokens: 260, + total_tokens: 798, + cache_read_tokens: 25, + cache_write_tokens: 13, + cost_usd: 0.006, + latency_ms: 500, + ts: 1_710_000_000_500, + }, + ]); + + vi.useRealTimers(); + }); +}); diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index d2bb3566fb2..cfe3188350d 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -81,6 +81,7 @@ interface ContainerTokenUsageOutput { total_tokens: number; cache_read_tokens: number; cache_write_tokens: number; + cost_usd?: number | null; latency_ms: number; ts: number; }; @@ -118,7 +119,10 @@ function log(message: string): void { console.error(`[agent-runner] ${message}`); } -export { resolveUsageModel } from './agent-backend.js'; +export { + captureTokenUsageSummaries, + resolveUsageModel, +} from './agent-backend.js'; async function main(): Promise { let containerInput: ContainerInput; diff --git a/src/agent/usage-actions.test.ts b/src/agent/usage-actions.test.ts index 5f31881f3e0..c0aa2fda397 100644 --- a/src/agent/usage-actions.test.ts +++ b/src/agent/usage-actions.test.ts @@ -268,8 +268,20 @@ describe('usage_get_summary action', () => { }); it('scopes results to sourceGroup for non-main callers', async () => { + agent.db.setRegisteredGroup('group-1@g.us', { + name: 'Group 1', + folder: 'test-group', + trigger: 'always', + added_at: '2026-04-19T00:00:00.000Z', + }); + agent.db.setRegisteredGroup('group-2@g.us', { + name: 'Group 2', + folder: 'other-group', + trigger: 'always', + added_at: '2026-04-19T00:00:00.000Z', + }); agent.db.recordTokenUsage({ - group_jid: 'test-group', + group_jid: 'group-1@g.us', session_id: 'session-local', model: 'claude-haiku-4-5', prompt_tokens: 50, @@ -278,7 +290,7 @@ describe('usage_get_summary action', () => { ts: 1_000, }); agent.db.recordTokenUsage({ - group_jid: 'other-group', + group_jid: 'group-2@g.us', session_id: 'session-other', model: 'claude-opus-4-6', prompt_tokens: 500, @@ -287,13 +299,19 @@ describe('usage_get_summary action', () => { ts: 2_000, }); - const response = await callSummary({ group_jid: 'other-group' }); + const response = await callSummary(); expect(response.status).toBe(200); expect(response.result.request_count).toBe(1); expect(response.result.total_tokens).toBe(75); expect(response.result.by_session).toHaveLength(1); expect(response.result.by_session[0]?.session_id).toBe('session-local'); + const forbidden = await callSummary({ group_jid: 'group-2@g.us' }); + expect(forbidden.status).toBe(200); + expect(forbidden.result.request_count).toBe(0); + expect(forbidden.result.total_tokens).toBe(0); + expect(forbidden.result.by_session).toHaveLength(0); + const mainResponse = await callSummaryAsMain(); expect(mainResponse.result.request_count).toBe(2); }); diff --git a/src/agent/usage-actions.ts b/src/agent/usage-actions.ts index 85fc33f10e7..5cc5530ec9e 100644 --- a/src/agent/usage-actions.ts +++ b/src/agent/usage-actions.ts @@ -1,7 +1,8 @@ import { z } from 'zod'; +import type { ActionContext } from '../api/action.js'; import type { Agent } from '../api/agent.js'; -import type { AgentDb } from '../db.js'; +import type { AgentDb, TokenUsageSummaryFilters } from '../db.js'; export function registerUsageActions(agent: Agent, db: AgentDb): void { agent.action( @@ -24,11 +25,34 @@ export function registerUsageActions(agent: Agent, db: AgentDb): void { 'Only include rows with a timestamp strictly greater than this Unix timestamp in milliseconds', ), }, - async (args, ctx) => { - const queryArgs = ctx.isMain - ? args - : { ...args, group_jid: ctx.sourceGroup }; - return db.getTokenUsageSummary(queryArgs); - }, + async (args, ctx) => + db.getTokenUsageSummary(scopeUsageSummaryFilters(args, ctx, db)), ); } + +function scopeUsageSummaryFilters( + args: TokenUsageSummaryFilters, + ctx: ActionContext, + db: AgentDb, +): TokenUsageSummaryFilters { + if (ctx.isMain) { + return args; + } + + const allowedJids = Object.entries(db.getAllRegisteredGroups()) + .filter(([, group]) => group.folder === ctx.sourceGroup) + .map(([jid]) => jid); + + if (args.group_jid) { + return { + ...args, + group_jids: allowedJids.includes(args.group_jid) ? [args.group_jid] : [], + group_jid: undefined, + }; + } + + return { + ...args, + group_jids: allowedJids, + }; +} diff --git a/src/container-runner.ts b/src/container-runner.ts index 97f980d5f18..2df75e32263 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -121,6 +121,7 @@ export interface ContainerTokenUsageEvent { total_tokens: number; cache_read_tokens: number; cache_write_tokens: number; + cost_usd?: number | null; latency_ms: number; ts: number; }; diff --git a/src/db.ts b/src/db.ts index 5c1600b5eed..1cb5704a59d 100644 --- a/src/db.ts +++ b/src/db.ts @@ -43,6 +43,7 @@ export interface TokenUsageRow { export interface TokenUsageSummaryFilters { group_jid?: string; + group_jids?: string[]; model?: string; since?: number; } @@ -85,6 +86,15 @@ function buildTokenUsageWhere(filters: TokenUsageSummaryFilters): { if (filters.group_jid) { clauses.push('group_jid = ?'); params.push(filters.group_jid); + } else if (filters.group_jids) { + if (filters.group_jids.length === 0) { + clauses.push('1 = 0'); + } else { + clauses.push( + `group_jid IN (${filters.group_jids.map(() => '?').join(', ')})`, + ); + params.push(...filters.group_jids); + } } if (filters.model) { clauses.push('model = ?');