@anhlp/alp-runtime 0.0.0-beta.1 → 0.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,394 @@
1
+ import type { ToolCallDetail } from '@anhlp/alp-protocol/events';
2
+
3
+ /**
4
+ * Typed tool-call details (ALPD §4.2): what each runtime reports of a tool call, as the
5
+ * provider-neutral shapes of Paseo's ToolCallDetail. Pure functions; a transport attaches the
6
+ * detail to the commandExecution it reports as before, and the runtime emits it.
7
+ *
8
+ * A function returns undefined for a call that stays `shell` as today (Claude's Bash, ACP's
9
+ * execute). A tool ALP cannot type gets `unknown` with its raw input and output.
10
+ */
11
+
12
+ export type { ToolCallDetail };
13
+
14
+ /** The bounds of a typed detail (not shell, not unknown), as the protocol states them. */
15
+ export const DETAIL_TEXT_CHARS = 65_536;
16
+ export const DETAIL_FILE_PATHS = 200;
17
+ export const DETAIL_WEB_RESULTS = 50;
18
+ export const ERROR_CHARS = 2_000;
19
+ export const TOOL_FAILED = 'Tool call failed';
20
+
21
+ /** The detail types of protocol v1; any other is a later one. */
22
+ export const DETAIL_TYPES = ['shell', 'read', 'edit', 'write', 'search', 'fetch', 'sub_agent', 'plan', 'plain_text', 'unknown'] as const;
23
+
24
+ /** Text kept to `max` characters: its head, then how much was left out. */
25
+ export function clipText(text: string, max = DETAIL_TEXT_CHARS): string {
26
+ if (text.length <= max) return text;
27
+ // Already clipped (a detail bounded twice): its marker keeps the count it has.
28
+ const marker = /\n… \(\d+ more characters\)$/.exec(text);
29
+ if (marker && marker.index <= max) return text;
30
+ // Never split a surrogate pair.
31
+ const end = /[\uD800-\uDBFF]/.test(text[max - 1] ?? '') ? max - 1 : max;
32
+ return `${text.slice(0, end)}\n… (${text.length - end} more characters)`;
33
+ }
34
+
35
+ /** A typed detail within the protocol's bounds; shell and unknown details pass unchanged. */
36
+ export function boundDetail(detail: ToolCallDetail): ToolCallDetail {
37
+ if (detail.type === 'shell' || detail.type === 'unknown') return detail;
38
+ const bounded: Record<string, unknown> = {};
39
+ let cut = false;
40
+ for (const [key, value] of Object.entries(detail)) {
41
+ if (value === undefined) continue;
42
+ if (typeof value === 'string') bounded[key] = key === 'type' ? value : clipText(value);
43
+ else if (key === 'filePaths' && Array.isArray(value)) {
44
+ bounded[key] = value.slice(0, DETAIL_FILE_PATHS).map(entry => clipText(String(entry)));
45
+ cut = value.length > DETAIL_FILE_PATHS;
46
+ } else if (key === 'webResults' && Array.isArray(value)) {
47
+ bounded[key] = (value as Array<{ title: string; url: string }>).slice(0, DETAIL_WEB_RESULTS).map(entry => ({ title: clipText(entry.title), url: clipText(entry.url) }));
48
+ } else bounded[key] = value;
49
+ }
50
+ // Paths left out make the list a truncated one.
51
+ if (cut) bounded.truncated = true;
52
+ return bounded as ToolCallDetail;
53
+ }
54
+
55
+ /** The error a failed call carries: the tool's own text, clipped, else a fixed one. */
56
+ export function toolError(text: unknown): string {
57
+ // Claude wraps a tool's error in <tool_use_error>.
58
+ const value = typeof text === 'string' ? text.trim().replace(/^<tool_use_error>([\s\S]*)<\/tool_use_error>$/, '$1').trim() : '';
59
+ return value ? clipText(value, ERROR_CHARS) : TOOL_FAILED;
60
+ }
61
+
62
+ const str = (value: unknown): string | undefined => typeof value === 'string' && value.trim() ? value : undefined;
63
+ const int = (value: unknown): number | undefined => typeof value === 'number' && Number.isInteger(value) ? value : undefined;
64
+ const record = (value: unknown): Record<string, any> | undefined => value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, any> : undefined;
65
+ const pathOf = (input: Record<string, any> | undefined) => str(input?.file_path) ?? str(input?.filePath) ?? str(input?.path) ?? str(input?.notebook_path) ?? str(input?.AbsolutePath) ?? str(input?.TargetFile);
66
+ /** Only the fields that are set. */
67
+ const defined = <T extends Record<string, unknown>>(value: T): T => Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) as T;
68
+ const typed = (detail: Record<string, unknown>): ToolCallDetail => boundDetail(defined(detail) as ToolCallDetail);
69
+
70
+ /** The text of a tool result: a string, or the text blocks of a content array. */
71
+ export function resultText(content: unknown): string | undefined {
72
+ if (typeof content === 'string') return content;
73
+ if (Array.isArray(content)) {
74
+ const parts = content.map(part => typeof part === 'string' ? part : typeof part?.text === 'string' ? part.text : '').filter(Boolean);
75
+ return parts.length ? parts.join('\n') : undefined;
76
+ }
77
+ return undefined;
78
+ }
79
+
80
+ const READ_GUTTER = /^\s*(\d+)(?:\t|→)(.*)$/;
81
+
82
+ /**
83
+ * Claude's Read output is `cat -n` style (` 12\tcode` or ` 12→code`); this is the raw text
84
+ * and the first line's number, or undefined unless every non-empty line carries the gutter with
85
+ * consecutive numbers. A reminder Claude appends after the content is dropped first.
86
+ */
87
+ export function stripReadGutter(text: string): { content: string; startLine: number } | undefined {
88
+ const body = text.replace(/\r\n/g, '\n').replace(/\n*<system-reminder>[\s\S]*?<\/system-reminder>\s*$/g, '');
89
+ const out: string[] = [];
90
+ let start: number | undefined;
91
+ let previous: number | undefined;
92
+ for (const line of body.split('\n')) {
93
+ if (!line) { out.push(line); continue; }
94
+ const match = READ_GUTTER.exec(line);
95
+ if (!match) return undefined;
96
+ const number = Number.parseInt(match[1], 10);
97
+ if (previous !== undefined && number !== previous + 1) return undefined;
98
+ start ??= number;
99
+ previous = number;
100
+ out.push(match[2]);
101
+ }
102
+ return start === undefined ? undefined : { content: out.join('\n'), startLine: start };
103
+ }
104
+
105
+ /** Hunks of Claude's structuredPatch as a unified diff. */
106
+ function structuredPatch(patch: unknown): string | undefined {
107
+ if (!Array.isArray(patch) || !patch.length) return undefined;
108
+ const hunks = patch.map((hunk: any) => {
109
+ const lines = Array.isArray(hunk?.lines) ? hunk.lines.map(String) : [];
110
+ return [`@@ -${hunk?.oldStart ?? 0},${hunk?.oldLines ?? 0} +${hunk?.newStart ?? 0},${hunk?.newLines ?? 0} @@`, ...lines].join('\n');
111
+ });
112
+ return `${hunks.join('\n')}\n`;
113
+ }
114
+
115
+ /** Several string replacements in one file as a diff-like text. */
116
+ function replacementsDiff(edits: Array<{ old_string?: unknown; new_string?: unknown }>): string {
117
+ const side = (text: unknown, mark: string) => String(text ?? '').split('\n').map(line => `${mark}${line}`);
118
+ return `${edits.map(edit => ['@@', ...side(edit.old_string, '-'), ...side(edit.new_string, '+')].join('\n')).join('\n')}\n`;
119
+ }
120
+
121
+ const TODO_MARK: Record<string, string> = { completed: '[x]', in_progress: '[~]', pending: '[ ]' };
122
+
123
+ /** What Claude's result for a call holds: the tool_result text and the SDK's structured output. */
124
+ export type ClaudeResult = { text?: string; structured?: unknown; error?: boolean };
125
+
126
+ /**
127
+ * The detail of a Claude Code tool call, from its name and input and, once done, its result.
128
+ * Undefined for Bash, which stays a shell command.
129
+ */
130
+ export function claudeToolDetail(name: string, rawInput: unknown, result?: ClaudeResult): ToolCallDetail | undefined {
131
+ if (name === 'Bash') return undefined;
132
+ const input = record(rawInput) ?? {};
133
+ const ok = result && !result.error;
134
+ const text = ok ? result.text : undefined;
135
+ const out = ok ? record(result.structured) : undefined;
136
+ const unknown = (): ToolCallDetail => ({ type: 'unknown', input: rawInput ?? null, output: result?.text ?? null });
137
+ const filePath = pathOf(input);
138
+
139
+ switch (name) {
140
+ case 'Read': {
141
+ if (!filePath) return unknown();
142
+ const file = out?.type === 'text' ? record(out.file) : undefined;
143
+ const stripped = file ? undefined : text !== undefined ? stripReadGutter(text) : undefined;
144
+ const content = typeof file?.content === 'string' ? file.content : stripped?.content ?? text;
145
+ // The first line's number is where a viewer's own gutter starts; before the result, the input's offset.
146
+ return typed({ type: 'read', filePath, content, offset: int(file?.startLine) ?? stripped?.startLine ?? int(input.offset), limit: int(input.limit) });
147
+ }
148
+ case 'Write':
149
+ if (!filePath) return unknown();
150
+ return typed({ type: 'write', filePath, content: typeof input.content === 'string' ? input.content : undefined });
151
+ case 'Edit':
152
+ if (!filePath) return unknown();
153
+ return typed({ type: 'edit', filePath, oldString: typeof input.old_string === 'string' ? input.old_string : undefined, newString: typeof input.new_string === 'string' ? input.new_string : undefined, unifiedDiff: structuredPatch(out?.structuredPatch) });
154
+ case 'MultiEdit': {
155
+ if (!filePath) return unknown();
156
+ const edits = Array.isArray(input.edits) ? input.edits.filter(record) : [];
157
+ if (edits.length === 1) return typed({ type: 'edit', filePath, oldString: str(edits[0].old_string) ?? undefined, newString: typeof edits[0].new_string === 'string' ? edits[0].new_string : undefined, unifiedDiff: structuredPatch(out?.structuredPatch) });
158
+ return typed({ type: 'edit', filePath, unifiedDiff: structuredPatch(out?.structuredPatch) ?? (edits.length ? replacementsDiff(edits) : undefined) });
159
+ }
160
+ case 'NotebookEdit':
161
+ if (!filePath) return unknown();
162
+ return typed({ type: 'edit', filePath, oldString: typeof out?.old_source === 'string' ? out.old_source : undefined, newString: input.edit_mode === 'delete' ? undefined : typeof input.new_source === 'string' ? input.new_source : undefined });
163
+ case 'Grep': {
164
+ const query = str(input.pattern);
165
+ if (!query) return unknown();
166
+ const structured = out && Array.isArray(out.filenames);
167
+ return typed({
168
+ type: 'search', query, toolName: 'grep',
169
+ content: structured ? (typeof out.content === 'string' ? out.content : undefined) : text,
170
+ filePaths: structured && out.filenames.length ? out.filenames.map(String) : undefined,
171
+ numFiles: structured ? int(out.numFiles) : undefined,
172
+ numMatches: structured ? int(out.numMatches) : undefined,
173
+ });
174
+ }
175
+ case 'Glob': {
176
+ const query = str(input.pattern);
177
+ if (!query) return unknown();
178
+ const structured = out && Array.isArray(out.filenames);
179
+ return typed({
180
+ type: 'search', query, toolName: 'glob',
181
+ content: structured ? undefined : text,
182
+ filePaths: structured && out.filenames.length ? out.filenames.map(String) : undefined,
183
+ numFiles: structured ? int(out.numFiles) : undefined,
184
+ truncated: structured && typeof out.truncated === 'boolean' ? out.truncated : undefined,
185
+ });
186
+ }
187
+ case 'WebFetch': {
188
+ const url = str(input.url) ?? str(out?.url);
189
+ if (!url) return unknown();
190
+ return typed({ type: 'fetch', url, prompt: str(input.prompt), result: typeof out?.result === 'string' ? out.result : text, code: int(out?.code) });
191
+ }
192
+ case 'WebSearch': {
193
+ const query = str(input.query) ?? str(out?.query);
194
+ if (!query) return unknown();
195
+ const entries: unknown[] = Array.isArray(out?.results) ? out.results : [];
196
+ const webResults = entries.flatMap(entry => Array.isArray(record(entry)?.content) ? (entry as any).content : [])
197
+ .filter((hit: any) => typeof hit?.title === 'string' && typeof hit?.url === 'string')
198
+ .map((hit: any) => ({ title: hit.title, url: hit.url }));
199
+ const notes = entries.filter((entry): entry is string => typeof entry === 'string');
200
+ return typed({ type: 'search', query, toolName: 'web_search', webResults: webResults.length ? webResults : undefined, content: out ? (notes.length ? notes.join('\n') : undefined) : text });
201
+ }
202
+ case 'Task':
203
+ case 'Agent': {
204
+ const report = out && Array.isArray(out.content) ? resultText(out.content) : text;
205
+ return typed({ type: 'sub_agent', subAgentType: str(input.subagent_type), description: str(input.description) ?? str(input.prompt), log: report });
206
+ }
207
+ case 'ExitPlanMode':
208
+ return str(input.plan) ? typed({ type: 'plan', text: input.plan }) : unknown();
209
+ case 'Skill':
210
+ return str(input.skill) ? typed({ type: 'plain_text', label: input.skill, text }) : unknown();
211
+ case 'TodoWrite': {
212
+ const todos = Array.isArray(input.todos) ? input.todos.filter(record) : [];
213
+ if (!todos.length) return unknown();
214
+ return typed({ type: 'plain_text', label: 'Todos', text: todos.map((todo: any) => `${TODO_MARK[todo.status] ?? '[ ]'} ${String(todo.content ?? todo.activeForm ?? '')}`).join('\n') });
215
+ }
216
+ default:
217
+ return unknown();
218
+ }
219
+ }
220
+
221
+ /** What ALP holds of an ACP tool call, merged from its tool_call and tool_call_update notifications. */
222
+ export type AcpCall = { kind?: string; title?: string; rawInput?: unknown; rawOutput?: unknown; locations?: unknown; content?: unknown };
223
+
224
+ /** The text parts of an ACP tool call's content. */
225
+ export function acpText(content: unknown): string | undefined {
226
+ const parts = (Array.isArray(content) ? content : [])
227
+ .map((part: any) => part?.type === 'content' && part.content?.type === 'text' ? part.content.text : '')
228
+ .filter(Boolean);
229
+ return parts.length ? parts.join('\n') : undefined;
230
+ }
231
+
232
+ /**
233
+ * The detail of an ACP tool call by its kind (read, edit, delete, move, search, fetch, think,
234
+ * other): its rawInput, rawOutput, locations and content, including diffs. Undefined for execute,
235
+ * which stays a shell command.
236
+ */
237
+ export function acpToolDetail(call: AcpCall): ToolCallDetail | undefined {
238
+ const kind = call.kind ?? 'other';
239
+ if (kind === 'execute') return undefined;
240
+ const input = record(call.rawInput);
241
+ const output = record(call.rawOutput);
242
+ const locations = (Array.isArray(call.locations) ? call.locations : []).map(record).filter((entry): entry is Record<string, any> => Boolean(str(entry?.path)));
243
+ const diffs = (Array.isArray(call.content) ? call.content : []).map(record).filter((part): part is Record<string, any> => part?.type === 'diff' && Boolean(str(part.path)));
244
+ const text = acpText(call.content) ?? (typeof call.rawOutput === 'string' ? call.rawOutput : str(output?.output) ?? str(output?.content));
245
+ const title = str(call.title);
246
+ const filePath = locations[0]?.path ?? pathOf(input);
247
+ const unknown = (): ToolCallDetail => ({ type: 'unknown', input: call.rawInput ?? null, output: call.rawOutput ?? text ?? null });
248
+
249
+ switch (kind) {
250
+ case 'read':
251
+ if (!filePath) return unknown();
252
+ return typed({ type: 'read', filePath, content: text, offset: int(input?.offset) ?? int(input?.line) ?? int(locations[0]?.line), limit: int(input?.limit) });
253
+ case 'edit':
254
+ case 'delete': {
255
+ const diff = diffs[0];
256
+ if (diff) {
257
+ const oldText = typeof diff.oldText === 'string' ? diff.oldText : undefined;
258
+ const newText = typeof diff.newText === 'string' ? diff.newText : undefined;
259
+ if (oldText === undefined && newText !== undefined && kind === 'edit') return typed({ type: 'write', filePath: diff.path, content: newText });
260
+ return typed({ type: 'edit', filePath: diff.path, oldString: oldText, newString: kind === 'delete' ? undefined : newText });
261
+ }
262
+ if (!filePath) return unknown();
263
+ const oldString = str(input?.old_string) ?? str(input?.oldText) ?? str(input?.old_str);
264
+ const newString = str(input?.new_string) ?? str(input?.newText) ?? str(input?.new_str);
265
+ const unifiedDiff = str(input?.diff) ?? str(input?.patch) ?? str(input?.unified_diff);
266
+ if (kind === 'edit' && !oldString && !unifiedDiff && typeof input?.content === 'string') return typed({ type: 'write', filePath, content: input.content });
267
+ return typed({ type: 'edit', filePath, oldString, newString, unifiedDiff });
268
+ }
269
+ case 'move': {
270
+ const from = filePath ?? str(input?.from) ?? str(input?.source);
271
+ const to = locations[1]?.path ?? str(input?.to) ?? str(input?.destination) ?? str(input?.new_path);
272
+ if (!from) return unknown();
273
+ return typed({ type: 'plain_text', label: title ?? 'Move', text: to ? `${from} → ${to}` : from });
274
+ }
275
+ case 'search': {
276
+ const query = str(input?.query) ?? str(input?.pattern) ?? str(input?.q) ?? title;
277
+ if (!query) return unknown();
278
+ const paths = locations.map(entry => entry.path as string);
279
+ return typed({ type: 'search', query, toolName: 'search', content: text, filePaths: paths.length ? paths : undefined });
280
+ }
281
+ case 'fetch': {
282
+ const url = str(input?.url);
283
+ if (url) return typed({ type: 'fetch', url, prompt: str(input?.prompt), result: text });
284
+ const query = str(input?.query) ?? str(input?.q);
285
+ if (query) return typed({ type: 'search', query, toolName: 'web_search', content: text });
286
+ return unknown();
287
+ }
288
+ case 'think':
289
+ return typed({ type: 'plain_text', label: title ?? 'Thinking', text: text ?? str(input?.thought) ?? str(input?.text) });
290
+ default:
291
+ return unknown();
292
+ }
293
+ }
294
+
295
+ /**
296
+ * The detail of an Antigravity tool step from its tool_info (name, parameters, output, error):
297
+ * run_command is a shell command, view_file a read, write_to_file a write, the replace tools
298
+ * edits; any other is unknown.
299
+ */
300
+ export function antigravityToolDetail(info: unknown): ToolCallDetail {
301
+ const tool = record(info);
302
+ const parameters = record(tool?.parameters) ?? {};
303
+ // A running step's output is empty: nothing yet.
304
+ const output = typeof tool?.output === 'string' && tool.output ? tool.output : undefined;
305
+ const filePath = str(parameters.AbsolutePath) ?? str(parameters.TargetFile);
306
+ switch (tool?.name) {
307
+ case 'run_command':
308
+ if (typeof parameters.CommandLine === 'string') return { type: 'shell', command: parameters.CommandLine, ...(str(parameters.Cwd) ? { cwd: parameters.Cwd } : {}), output: output ?? '' };
309
+ break;
310
+ case 'view_file':
311
+ if (filePath) return typed({ type: 'read', filePath, content: output, offset: int(parameters.StartLine), limit: int(parameters.EndLine) !== undefined && int(parameters.StartLine) !== undefined ? parameters.EndLine - parameters.StartLine + 1 : undefined });
312
+ break;
313
+ case 'write_to_file':
314
+ if (filePath) return typed({ type: 'write', filePath, content: typeof parameters.CodeContent === 'string' ? parameters.CodeContent : undefined });
315
+ break;
316
+ case 'replace_file_content':
317
+ case 'multi_replace_file_content':
318
+ if (filePath) return typed({ type: 'edit', filePath, oldString: typeof parameters.TargetContent === 'string' ? parameters.TargetContent : undefined, newString: typeof parameters.ReplacementContent === 'string' ? parameters.ReplacementContent : undefined });
319
+ break;
320
+ }
321
+ return { type: 'unknown', input: tool?.parameters ?? null, output: tool?.error?.message ?? output ?? null };
322
+ }
323
+
324
+ /** A Codex native item ALP shows as a tool call. */
325
+ export type CodexToolCall = { name: string; status: 'running' | 'completed' | 'failed'; detail: ToolCallDetail; error?: string };
326
+
327
+ /** `--- a/<path>` / `+++ b/<path>` headers on a file's diff that lacks them. */
328
+ function withHeaders(path: string, diff: string) {
329
+ // Only a diff that starts with its header has one: a removed `-- x` line inside is not it.
330
+ return /^(--- |diff --git )/.test(diff) ? diff : `--- a/${path}\n+++ b/${path}\n${diff}`;
331
+ }
332
+
333
+ /** Text that starts as a unified diff does: a file header or a hunk (a line inside content does not count). */
334
+ const looksLikeDiff = (text: string) => /^(@@ |--- |\+\+\+ |diff --git )/.test(text);
335
+
336
+ /**
337
+ * A change's diff text as a unified diff. Codex reports an added or deleted file's whole content
338
+ * in `diff` (codex-rs app-server: FileChange::Add/Delete carry `content`), which becomes one hunk
339
+ * of additions or removals; an update's diff, or text that already is one, stays as it is.
340
+ */
341
+ function changeDiff(kind: unknown, diff: string): string {
342
+ if ((kind !== 'add' && kind !== 'delete') || !diff || looksLikeDiff(diff)) return diff;
343
+ const lines = diff.replace(/\r\n?/g, '\n').replace(/\n$/, '').split('\n');
344
+ const n = lines.length;
345
+ return kind === 'add'
346
+ ? `@@ -0,0 +1,${n} @@\n${lines.map(line => `+${line}`).join('\n')}\n`
347
+ : `@@ -1,${n} +0,0 @@\n${lines.map(line => `-${line}`).join('\n')}\n`;
348
+ }
349
+
350
+ /**
351
+ * Codex's native fileChange, webSearch, mcpToolCall and collabAgentToolCall items as tool calls,
352
+ * `done` when Codex reported the item completed; undefined for any other item.
353
+ */
354
+ export function codexToolCall(item: any, done: boolean): CodexToolCall | undefined {
355
+ const raw = String(item?.status ?? '');
356
+ // An item without a status (a web search) is running until Codex says it completed.
357
+ const status = raw === 'inProgress' || (!raw && !done) ? 'running' : raw === 'failed' || raw === 'declined' ? 'failed' : 'completed';
358
+ const failed = (error?: unknown) => status === 'failed' ? { error: toolError(error) } : {};
359
+
360
+ if (item?.type === 'fileChange') {
361
+ const changes = (Array.isArray(item.changes) ? item.changes : []).filter((change: any) => str(change?.path));
362
+ if (!changes.length) return { name: 'apply_patch', status, detail: { type: 'unknown', input: item.changes ?? null, output: null }, ...failed() };
363
+ let detail: ToolCallDetail;
364
+ if (changes.length === 1) {
365
+ const [change] = changes;
366
+ const diff = typeof change.diff === 'string' ? change.diff : '';
367
+ const kind = change.kind?.type ?? change.kind;
368
+ detail = kind === 'add' && !looksLikeDiff(diff)
369
+ ? typed({ type: 'write', filePath: change.path, content: diff || undefined })
370
+ : typed({ type: 'edit', filePath: change.path, unifiedDiff: changeDiff(kind, diff) || undefined });
371
+ } else {
372
+ const diff = changes.map((change: any) => {
373
+ const text = typeof change.diff === 'string' ? changeDiff(change.kind?.type ?? change.kind, change.diff) : '';
374
+ return text ? withHeaders(change.path, text.endsWith('\n') ? text : `${text}\n`) : `--- a/${change.path}\n+++ b/${change.path}\n`;
375
+ }).join('');
376
+ detail = typed({ type: 'edit', filePath: changes[0].path, unifiedDiff: diff });
377
+ }
378
+ return { name: 'apply_patch', status, detail, ...failed() };
379
+ }
380
+
381
+ if (item?.type === 'webSearch') {
382
+ const query = str(item.query) ?? str(item.action?.query) ?? (Array.isArray(item.action?.queries) ? str(item.action.queries[0]) : undefined) ?? '';
383
+ return { name: 'web_search', status, detail: typed({ type: 'search', query, toolName: 'web_search' }), ...failed() };
384
+ }
385
+
386
+ if (item?.type === 'mcpToolCall') {
387
+ return { name: `mcp__${String(item.server ?? '')}__${String(item.tool ?? '')}`, status, detail: { type: 'unknown', input: item.arguments ?? null, output: item.result ?? null }, ...failed(item.error?.message) };
388
+ }
389
+
390
+ if (item?.type === 'collabAgentToolCall') {
391
+ return { name: 'Sub-agent', status, detail: typed({ type: 'sub_agent', subAgentType: 'Sub-agent', description: str(item.prompt) }), ...failed(item.error?.message) };
392
+ }
393
+ return undefined;
394
+ }
package/src/usage.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { SessionUsage } from './events.js';
2
+
1
3
  /** Account-free usage data shown in ALP's viewers. */
2
4
  export type UsageWindow = { name: string; usedPercent: number | null; resetsAt: string | null };
3
5
  export type ProviderUsage = {
@@ -40,3 +42,38 @@ export function providerUsage(runtime: ProviderUsage['runtime'], usage: any, che
40
42
  windows,
41
43
  };
42
44
  }
45
+
46
+ const tokens = (value: unknown, least = 0): number | undefined =>
47
+ typeof value === 'number' && Number.isInteger(value) && value >= least ? value : undefined;
48
+
49
+ /**
50
+ * A session's usage after one token report (ALPD §57), as Paseo's AgentUsage. Every transport
51
+ * reports as Codex's thread/tokenUsage/updated does: `last` the latest model call, of which
52
+ * `totalTokens` is what the context holds now; `modelContextWindow` the window once known;
53
+ * `totalCostUsd` the session's cost where the runtime counts it. The fields a report carries
54
+ * replace the session's; the others are kept, except that the latest response's input, cached and
55
+ * output counts are replaced together. Undefined when the report changes nothing.
56
+ */
57
+ export function sessionUsage(previous: SessionUsage | undefined, report: any): SessionUsage | undefined {
58
+ if (!report || typeof report !== 'object') return undefined;
59
+ const last = report.last && typeof report.last === 'object' ? report.last : {};
60
+ const cost = report.totalCostUsd;
61
+ const fields: SessionUsage = {
62
+ contextWindowUsedTokens: tokens(last.totalTokens),
63
+ contextWindowMaxTokens: tokens(report.modelContextWindow, 1),
64
+ inputTokens: tokens(last.inputTokens),
65
+ cachedInputTokens: tokens(last.cachedInputTokens ?? report.cachedInputTokens),
66
+ outputTokens: tokens(last.outputTokens),
67
+ totalCostUsd: typeof cost === 'number' && Number.isFinite(cost) && cost >= 0 ? cost : undefined,
68
+ };
69
+ const next: SessionUsage = { ...previous };
70
+ // The latest response's counts go together: a report with any of them replaces all three, so a
71
+ // count an earlier response had never sits beside a newer one's.
72
+ if (fields.inputTokens !== undefined || fields.cachedInputTokens !== undefined || fields.outputTokens !== undefined) {
73
+ delete next.inputTokens; delete next.cachedInputTokens; delete next.outputTokens;
74
+ }
75
+ for (const [key, value] of Object.entries(fields) as Array<[keyof SessionUsage, number | undefined]>) if (value !== undefined) next[key] = value;
76
+ const keys = Object.keys(next) as Array<keyof SessionUsage>;
77
+ if (keys.length === Object.keys(previous ?? {}).length && keys.every(key => previous?.[key] === next[key])) return undefined;
78
+ return next;
79
+ }