pum-agent 0.1.0-beta.3

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eugen1763
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,196 @@
1
+ <div align="center">
2
+
3
+ # PUM
4
+
5
+ **A compact coding agent for the terminal.**
6
+
7
+ Plan, edit, run commands, review Markdown, and coordinate parallel Git worktrees without leaving the TUI.
8
+
9
+ [![CI](https://github.com/eugen1763/Pum/actions/workflows/ci.yml/badge.svg)](https://github.com/eugen1763/Pum/actions/workflows/ci.yml)
10
+ [![npm beta](https://img.shields.io/npm/v/pum-agent/beta?label=npm%20beta)](https://www.npmjs.com/package/pum-agent)
11
+ [![License: MIT](https://img.shields.io/github/license/eugen1763/Pum)](LICENSE)
12
+ [![Runtime: Bun](https://img.shields.io/badge/runtime-Bun-f9f1e1?logo=bun&logoColor=14151a)](https://bun.sh)
13
+
14
+ </div>
15
+
16
+ > [!WARNING]
17
+ > PUM can read, write, and delete files. PUM can also run shell commands without approval. Start PUM only inside a workspace where these actions are acceptable. Check mode adds a verifier, but it is not a sandbox.
18
+
19
+ ## See PUM in action
20
+
21
+ The following screens are real OpenTUI renders captured through `tmux`. A local mock provider supplied the model output.
22
+
23
+ ![PUM transcript showing Markdown and a read tool call](docs/images/pum-transcript.svg)
24
+
25
+ <details>
26
+ <summary><strong>Settings panel</strong></summary>
27
+
28
+ ![PUM settings panel](docs/images/pum-settings.svg)
29
+
30
+ </details>
31
+
32
+ ## Why PUM
33
+
34
+ - **Compact terminal UI:** Streaming Markdown, syntax highlighting, thinking traces, tool rows, usage, cost, and Git status.
35
+ - **Full coding loop:** Built-in `read`, `write`, `edit`, `bash`, and atomic `apply_patch` tools.
36
+ - **Parallel subagents:** Persistent agents work in isolated Git worktrees and report to the main agent.
37
+ - **Prompt control:** Steer active work, stash prompt batches, attach clipboard images, cancel turns, and resume sessions.
38
+ - **Provider choice:** Use the login methods exposed by pi, or add an OpenAI-compatible custom endpoint.
39
+ - **Optional safeguards:** Enable fail-closed Check mode for `bash`, `edit`, and `apply_patch` calls.
40
+ - **Terminal-first appearance:** Nine themes, semantic color overrides, Unicode glyphs, and optional animation.
41
+
42
+ PUM uses [pi](https://github.com/earendil-works/pi) for the agent loop and [OpenTUI](https://github.com/anomalyco/opentui) for rendering.
43
+
44
+ ## Requirements
45
+
46
+ - [Bun](https://bun.sh)
47
+ - Git
48
+ - An interactive terminal with ANSI/VT and Unicode support
49
+ - Credentials for a supported provider, or an OpenAI-compatible endpoint
50
+
51
+ Linux and macOS are the primary environments. Windows CI checks the code and Windows path behavior. Native Windows TUI operation remains provisional because it has not been fully validated in a Windows terminal.
52
+
53
+ On Windows, install Git for Windows. Ensure that `bash.exe` is in `PATH` or remains in its standard location. Use Windows Terminal with PowerShell. Do not use PowerShell ISE.
54
+
55
+ ## Install and start
56
+
57
+ ### Install from source
58
+
59
+ ```bash
60
+ git clone https://github.com/eugen1763/Pum.git
61
+ cd Pum
62
+ bun install --frozen-lockfile
63
+ bun run login
64
+ bun run start
65
+ ```
66
+
67
+ PUM opens the login panel automatically when no provider is available. Use `/login` later to add or update a provider.
68
+
69
+ Resume the latest session for the current directory:
70
+
71
+ ```bash
72
+ bun run start -r
73
+ ```
74
+
75
+ ### Install the beta package
76
+
77
+ ```bash
78
+ bun i -g pum-agent@beta
79
+ pum
80
+ ```
81
+
82
+ The package is named `pum-agent` because the bare `pum` name is already owned. The installed command is still `pum`.
83
+
84
+ ## Essential controls
85
+
86
+ | Key | Action |
87
+ |---|---|
88
+ | `Enter` | Send a prompt, or steer the selected working agent |
89
+ | `Ctrl+Enter` / `Shift+Enter` | Insert a new line |
90
+ | `Alt+Enter` | Stash the prompt without sending |
91
+ | `Tab` | Open the prompt stash on an empty input |
92
+ | `Shift+↑` / `Shift+↓` | Select a range of stashed tasks |
93
+ | `Alt+V` | Attach an image from the graphical clipboard |
94
+ | `Ctrl+L` | Open the agent transcript selector |
95
+ | `Shift+Tab` / `Ctrl+Shift+Tab` | Cycle through agent transcripts |
96
+ | `Ctrl+H` | Open session history when the terminal reports the key distinctly |
97
+ | `Ctrl+P` | Open settings |
98
+ | `Esc` twice | Cancel the selected working agent |
99
+ | `Ctrl+C` twice | Quit |
100
+ | `?` | Show all controls when the prompt is empty |
101
+
102
+ Useful commands include `/login`, `/history`, `/clear`, `/compress`, and `/worktree`.
103
+
104
+ ## Parallel subagents
105
+
106
+ PUM can run up to five active subagents. Each subagent has these resources:
107
+
108
+ - A persistent pi session
109
+ - An isolated branch and worktree under `.pum/worktrees`
110
+ - Its own transcript, draft, usage data, and cancellation state
111
+ - Tools for progress messages and a single final completion report
112
+
113
+ Select a range of stashed prompts and press `Enter`. The main agent can group related work and run independent groups in parallel. Successful managed merges remove the completed worktree and branch.
114
+
115
+ Use `Ctrl+L` to select an agent transcript. Input then goes to that agent. Finished or interrupted agents remain available until PUM merges or removes them.
116
+
117
+ ## Tools and safeguards
118
+
119
+ ### Atomic `apply_patch`
120
+
121
+ `apply_patch` supports add, update, delete, move, multiple files, and multiple hunks. PUM validates the full patch before changing files. It rejects traversal, absolute paths, escaping symlinks, path conflicts, and ambiguous context. A failed commit restores all touched files.
122
+
123
+ ### Check mode
124
+
125
+ Enable Check mode in `Ctrl+P`. PUM sends each proposed `bash`, `edit`, or `apply_patch` call to a separate verifier model. The verifier must return a clear `SAFE` decision. Errors, timeouts, unclear replies, and explicit rejections block the tool.
126
+
127
+ PUM caches only a narrow set of accepted read-only Git inspection commands. It never caches mutation checks. Check mode is off by default and does not replace isolation, backups, or code review.
128
+
129
+ ### Hosted web search
130
+
131
+ Web search is on by default for supported OpenAI Codex providers. Searches appear as transcript tool rows and persist in resumed sessions. Other providers continue without the hosted search tool. Disable web search in `Ctrl+P`.
132
+
133
+ ### Themes and Markdown
134
+
135
+ PUM includes `tokyonight`, `gruvbox`, `catppuccin`, `nord`, `dracula`, `rosepine`, `solarized`, `kanagawa`, and `github-light`. Select a preset in `Ctrl+P`.
136
+
137
+ Create `theme.json` in the PUM config directory to override semantic tokens:
138
+
139
+ ```json
140
+ {
141
+ "accent": "#ff7a93",
142
+ "userBg": "#2a2f45"
143
+ }
144
+ ```
145
+
146
+ Markdown renders while streaming. OpenTUI provides syntax parsers for JavaScript, TypeScript, Zig, and Markdown. Other fenced languages still render as code blocks without syntax colors.
147
+
148
+ ## Configuration and data
149
+
150
+ Set `PUM_DIR` to override the complete PUM data directory.
151
+
152
+ | Platform | Default directory |
153
+ |---|---|
154
+ | Linux | `$XDG_CONFIG_HOME/pum` or `~/.config/pum` |
155
+ | macOS | `~/Library/Application Support/pum` |
156
+ | Windows | `%LOCALAPPDATA%\pum`, with `%APPDATA%\pum` as fallback |
157
+
158
+ | Path | Purpose |
159
+ |---|---|
160
+ | `auth.json` | Provider credentials and custom-provider keys |
161
+ | `models.json` | Custom endpoints and model metadata; submitted keys are not stored here |
162
+ | `settings.json` | Model and thinking level managed by pi |
163
+ | `pum.json` | Theme, animation, search, writing, explanation, and check settings |
164
+ | `theme.json` | Optional semantic color overrides |
165
+ | `history.json` | Prompt history by working directory |
166
+ | `prompt-stash.json` | Stashed prompts by working directory |
167
+ | `sessions/` | Main conversation sessions |
168
+ | `subagents/` | Persistent subagent sessions |
169
+ | `check-mode-cache.json` | Accepted checks for eligible read-only Git commands |
170
+
171
+ PUM keeps this directory separate from pi's default configuration directory.
172
+
173
+ ## Development
174
+
175
+ ```bash
176
+ bun install --frozen-lockfile
177
+ bun test
178
+ bun run typecheck
179
+ git diff --check
180
+ ```
181
+
182
+ Run the TUI from the repository root:
183
+
184
+ ```bash
185
+ bun run start
186
+ ```
187
+
188
+ Use a throwaway `PUM_DIR` for local integration tests. Capture TUI output through `tmux`; do not pipe standard output. See [AGENTS.md](AGENTS.md) for architecture, locked decisions, and TUI test guidance.
189
+
190
+ ## Release status
191
+
192
+ PUM `0.1` is in beta. Interfaces and persisted formats can still change before the stable release. Review the release notes before upgrading sessions or custom configuration.
193
+
194
+ ## License
195
+
196
+ PUM is available under the [MIT License](LICENSE).
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "pum-agent",
3
+ "version": "0.1.0-beta.3",
4
+ "description": "A compact terminal coding agent powered by pi and OpenTUI.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/eugen1763/Pum.git"
10
+ },
11
+ "homepage": "https://github.com/eugen1763/Pum#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/eugen1763/Pum/issues"
14
+ },
15
+ "keywords": [
16
+ "ai",
17
+ "coding-agent",
18
+ "terminal",
19
+ "tui",
20
+ "bun",
21
+ "opentui"
22
+ ],
23
+ "engines": {
24
+ "bun": ">=1.3.0"
25
+ },
26
+ "os": [
27
+ "darwin",
28
+ "linux",
29
+ "win32"
30
+ ],
31
+ "files": [
32
+ "src/**/*.ts",
33
+ "src/**/*.tsx",
34
+ "!src/**/*.test.ts",
35
+ "!src/**/*.test.tsx",
36
+ "LICENSE"
37
+ ],
38
+ "publishConfig": {
39
+ "access": "public",
40
+ "provenance": true
41
+ },
42
+ "bin": {
43
+ "pum": "src/index.tsx"
44
+ },
45
+ "scripts": {
46
+ "start": "bun run src/index.tsx",
47
+ "login": "bun run src/index.tsx login",
48
+ "test": "bun test",
49
+ "typecheck": "bunx tsc --noEmit",
50
+ "pack:check": "bun run scripts/validate-pack.ts"
51
+ },
52
+ "dependencies": {
53
+ "@earendil-works/pi-ai": "^0.84.1",
54
+ "@earendil-works/pi-coding-agent": "^0.84.1",
55
+ "@opentui/core": "^0.5.1",
56
+ "@opentui/react": "^0.5.1",
57
+ "react": "^19.2.8",
58
+ "react-devtools-core": "^7.0.1",
59
+ "typebox": "^1.3.7",
60
+ "ws": "^8.21.3"
61
+ },
62
+ "optionalDependencies": {
63
+ "@mariozechner/clipboard": "0.3.9"
64
+ },
65
+ "devDependencies": {
66
+ "@types/bun": "^1.3.14",
67
+ "@types/react": "^19.2.18"
68
+ }
69
+ }
@@ -0,0 +1,217 @@
1
+ import { StyledText, type ScrollBoxRenderable } from "@opentui/core";
2
+ import { useTerminalDimensions } from "@opentui/react";
3
+ import { useEffect, useRef } from "react";
4
+ import { normalizeAgentUsage } from "./agent-usage";
5
+ import {
6
+ fitStatusMetadata,
7
+ statusMetadataChunks,
8
+ statusMetadataItems,
9
+ statusMetadataWidth,
10
+ type StatusMetadataValues,
11
+ } from "./status-metadata";
12
+ import type { Theme } from "./theme";
13
+ import type { SubagentSnapshot } from "./subagents/types";
14
+
15
+ export type AgentTreeRow = {
16
+ id: string | null;
17
+ name: string;
18
+ status?: SubagentSnapshot["status"];
19
+ depth: number;
20
+ metadata?: StatusMetadataValues;
21
+ };
22
+
23
+ export type AgentSelectorRowLayout = {
24
+ indent: number;
25
+ label: string;
26
+ labelWidth: number;
27
+ metadata: ReturnType<typeof statusMetadataItems>;
28
+ metadataWidth: number;
29
+ };
30
+
31
+ export function agentSelectorRowLayout(
32
+ row: AgentTreeRow,
33
+ popupColumns: number,
34
+ ): AgentSelectorRowLayout {
35
+ const indent = Math.min(2 + row.depth * 2, Math.max(2, popupColumns - 12));
36
+ // One column remains clear for the pinned scrollbar.
37
+ const contentColumns = Math.max(1, popupColumns - indent - 1);
38
+ const label = row.status ? `${row.name} · ${row.status}` : row.name;
39
+ const minimumLabelWidth = Math.min(
40
+ label.length,
41
+ Math.max(8, Math.ceil(contentColumns * 0.45)),
42
+ );
43
+ const availableMetadataWidth = Math.max(0, contentColumns - minimumLabelWidth - 2);
44
+ const metadata = fitStatusMetadata(
45
+ row.metadata ? statusMetadataItems(row.metadata) : [],
46
+ availableMetadataWidth,
47
+ );
48
+ const metadataWidth = statusMetadataWidth(metadata);
49
+ const labelWidth = Math.max(1, contentColumns - (metadataWidth ? metadataWidth + 2 : 0));
50
+ return { indent, label, labelWidth, metadata, metadataWidth };
51
+ }
52
+
53
+ export function buildAgentTree(agents: readonly SubagentSnapshot[]): AgentTreeRow[] {
54
+ const children = new Map<string | null, SubagentSnapshot[]>();
55
+ const ids = new Set(agents.map((agent) => agent.id));
56
+ for (const agent of agents) {
57
+ const parent = agent.parentAgentId && ids.has(agent.parentAgentId)
58
+ ? agent.parentAgentId
59
+ : null;
60
+ children.set(parent, [...(children.get(parent) ?? []), agent]);
61
+ }
62
+ for (const values of children.values()) values.sort((a, b) => a.startedAt - b.startedAt);
63
+
64
+ const rows: AgentTreeRow[] = [{ id: null, name: "main", depth: 0 }];
65
+ const seen = new Set<string>();
66
+ const visit = (parentId: string | null, depth: number) => {
67
+ for (const agent of children.get(parentId) ?? []) {
68
+ if (seen.has(agent.id)) continue;
69
+ seen.add(agent.id);
70
+ const usage = normalizeAgentUsage(agent.usage);
71
+ rows.push({
72
+ id: agent.id,
73
+ name: agent.name,
74
+ status: agent.status,
75
+ depth,
76
+ metadata: {
77
+ branch: agent.worktree.branch ?? null,
78
+ outgoingTokens: usage.outgoing,
79
+ incomingTokens: usage.incoming,
80
+ cacheReadTokens: usage.cacheRead,
81
+ cost: usage.cost,
82
+ contextPct: usage.contextPct,
83
+ },
84
+ });
85
+ visit(agent.id, depth + 1);
86
+ }
87
+ };
88
+ visit(null, 1);
89
+ // A corrupt parent cycle must not hide a retained agent.
90
+ for (const agent of agents) {
91
+ if (seen.has(agent.id)) continue;
92
+ const usage = normalizeAgentUsage(agent.usage);
93
+ rows.push({
94
+ id: agent.id,
95
+ name: agent.name,
96
+ status: agent.status,
97
+ depth: 1,
98
+ metadata: {
99
+ branch: agent.worktree.branch ?? null,
100
+ outgoingTokens: usage.outgoing,
101
+ incomingTokens: usage.incoming,
102
+ cacheReadTokens: usage.cacheRead,
103
+ cost: usage.cost,
104
+ contextPct: usage.contextPct,
105
+ },
106
+ });
107
+ }
108
+ return rows;
109
+ }
110
+
111
+ export function moveAgentSelection(current: number, count: number, direction: -1 | 1): number {
112
+ if (count <= 0) return 0;
113
+ return (current + direction + count) % count;
114
+ }
115
+
116
+ export function AgentSelectorPopup({
117
+ theme,
118
+ rows,
119
+ cursor,
120
+ }: {
121
+ theme: Theme;
122
+ rows: readonly AgentTreeRow[];
123
+ cursor: number;
124
+ }) {
125
+ const { width, height } = useTerminalDimensions();
126
+ const scrollRef = useRef<ScrollBoxRenderable>(null);
127
+ const narrow = width < 50;
128
+ const popupWidth = narrow ? Math.max(12, width - 2) : Math.floor(width * 0.7);
129
+ const popupColumns = Math.max(8, popupWidth - 5);
130
+ const popupHeight = Math.min(
131
+ rows.length + (narrow ? 10 : 7),
132
+ Math.max(7, Math.floor(height * (narrow ? 0.85 : 0.7))),
133
+ );
134
+
135
+ useEffect(() => {
136
+ const timer = setTimeout(() => {
137
+ scrollRef.current?.scrollChildIntoView(`agent-tree-${cursor}`);
138
+ }, 0);
139
+ return () => clearTimeout(timer);
140
+ }, [cursor]);
141
+
142
+ return (
143
+ <box
144
+ title=" Agents "
145
+ style={{
146
+ position: "absolute",
147
+ top: narrow ? 1 : "15%",
148
+ left: narrow ? 1 : "15%",
149
+ width: popupWidth,
150
+ height: popupHeight,
151
+ zIndex: 100,
152
+ border: true,
153
+ borderColor: theme.border,
154
+ backgroundColor: theme.popupBg,
155
+ flexDirection: "column",
156
+ padding: 1,
157
+ }}
158
+ >
159
+ <scrollbox
160
+ ref={scrollRef}
161
+ style={{ flexGrow: 1, minHeight: 1 }}
162
+ verticalScrollbarOptions={{ visible: true }}
163
+ >
164
+ <box style={{ flexDirection: "column", width: "100%", flexShrink: 0 }}>
165
+ {rows.map((row, index) => {
166
+ const selected = index === cursor;
167
+ const layout = agentSelectorRowLayout(row, popupColumns);
168
+ return (
169
+ <box
170
+ id={`agent-tree-${index}`}
171
+ key={row.id ?? "main"}
172
+ style={{
173
+ flexDirection: "row",
174
+ width: "100%",
175
+ flexShrink: 0,
176
+ backgroundColor: selected ? theme.selectionBg : theme.popupBg,
177
+ }}
178
+ >
179
+ <box style={{ width: layout.indent, height: 1, flexShrink: 0 }}>
180
+ {selected ? <text content="› " fg={theme.accent} bg={theme.selectionBg} /> : null}
181
+ </box>
182
+ <text
183
+ content={layout.label}
184
+ fg={selected ? theme.fg : theme.dim}
185
+ bg={selected ? theme.selectionBg : theme.popupBg}
186
+ wrapMode="none"
187
+ style={{ width: layout.labelWidth, height: 1, flexShrink: 0 }}
188
+ />
189
+ {layout.metadataWidth ? (
190
+ <>
191
+ <box style={{ width: 2, height: 1, flexShrink: 0 }} />
192
+ <text
193
+ content={new StyledText(statusMetadataChunks(layout.metadata, theme))}
194
+ bg={selected ? theme.selectionBg : theme.popupBg}
195
+ wrapMode="none"
196
+ style={{ width: layout.metadataWidth, height: 1, flexShrink: 0 }}
197
+ />
198
+ </>
199
+ ) : null}
200
+ {/* Reserve the pinned scrollbar column so it cannot cover text. */}
201
+ <box style={{ width: 1, height: 1, flexShrink: 0 }} />
202
+ </box>
203
+ );
204
+ })}
205
+ </box>
206
+ </scrollbox>
207
+ <box style={{ height: 1, flexShrink: 0 }} />
208
+ <text
209
+ content="↑↓ select →/enter open esc close"
210
+ fg={theme.dim}
211
+ bg={theme.popupBg}
212
+ wrapMode="word"
213
+ style={{ flexShrink: 0, width: "100%" }}
214
+ />
215
+ </box>
216
+ );
217
+ }
@@ -0,0 +1,93 @@
1
+ import type { Usage } from "@earendil-works/pi-ai";
2
+
3
+ export type AgentUsage = {
4
+ /** Tokens PUM sent to the model, including tokens written to a provider cache. */
5
+ outgoing: number;
6
+ /** Tokens generated by the model. Pi includes reported reasoning tokens here. */
7
+ incoming: number;
8
+ /** Prompt tokens served from a provider cache. */
9
+ cacheRead: number;
10
+ cost: number;
11
+ contextPct: number | null;
12
+ };
13
+
14
+ export const emptyAgentUsage = (): AgentUsage => ({
15
+ outgoing: 0,
16
+ incoming: 0,
17
+ cacheRead: 0,
18
+ cost: 0,
19
+ contextPct: null,
20
+ });
21
+
22
+ /** Convert retained pre-metric usage records without discarding their token total. */
23
+ export function normalizeAgentUsage(value: unknown): AgentUsage {
24
+ if (!value || typeof value !== "object") return emptyAgentUsage();
25
+ const usage = value as Record<string, unknown>;
26
+ const cost = typeof usage.cost === "number" ? usage.cost : 0;
27
+ const contextPct = typeof usage.contextPct === "number" ? usage.contextPct : null;
28
+ if (
29
+ typeof usage.outgoing === "number" ||
30
+ typeof usage.incoming === "number" ||
31
+ typeof usage.cacheRead === "number"
32
+ ) {
33
+ return {
34
+ outgoing: typeof usage.outgoing === "number" ? usage.outgoing : 0,
35
+ incoming: typeof usage.incoming === "number" ? usage.incoming : 0,
36
+ cacheRead: typeof usage.cacheRead === "number" ? usage.cacheRead : 0,
37
+ cost,
38
+ contextPct,
39
+ };
40
+ }
41
+
42
+ // Legacy records stored only totalTokens. The exact split is unavailable.
43
+ // Keep the total visible as outgoing until a retained session can rebuild it.
44
+ return {
45
+ outgoing: typeof usage.tokens === "number" ? usage.tokens : 0,
46
+ incoming: 0,
47
+ cacheRead: 0,
48
+ cost,
49
+ contextPct,
50
+ };
51
+ }
52
+
53
+ type TurnUsage = Partial<Omit<Usage, "cost">> & {
54
+ cost?: Partial<Usage["cost"]>;
55
+ };
56
+
57
+ export function addTurnUsage(
58
+ current: AgentUsage,
59
+ usage: TurnUsage | undefined,
60
+ contextWindow: number | undefined,
61
+ ): AgentUsage {
62
+ if (!usage) return current;
63
+ const input = usage.input ?? 0;
64
+ const output = usage.output ?? 0;
65
+ const cacheRead = usage.cacheRead ?? 0;
66
+ const cacheWrite = usage.cacheWrite ?? 0;
67
+ const promptTokens = input + cacheRead + cacheWrite;
68
+ return {
69
+ outgoing: current.outgoing + input + cacheWrite,
70
+ incoming: current.incoming + output,
71
+ cacheRead: current.cacheRead + cacheRead,
72
+ cost: current.cost + (usage.cost?.total ?? 0),
73
+ contextPct: contextWindow
74
+ ? Math.min(100, Math.round((promptTokens / contextWindow) * 100))
75
+ : null,
76
+ };
77
+ }
78
+
79
+ export function usageFromEntries(
80
+ entries: readonly any[],
81
+ contextWindow: number | undefined,
82
+ ): AgentUsage {
83
+ let result = emptyAgentUsage();
84
+ for (const entry of entries) {
85
+ const usage = entry?.type === "message" && entry.message?.role === "assistant"
86
+ ? entry.message.usage
87
+ : (entry?.type === "compaction" || entry?.type === "branch_summary")
88
+ ? entry.usage
89
+ : undefined;
90
+ if (usage) result = addTurnUsage(result, usage, contextWindow);
91
+ }
92
+ return result;
93
+ }