@youngjurry/pi-agents 0.8.0 → 0.9.0
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/CHANGELOG.md +13 -0
- package/README.md +4 -2
- package/control.ts +66 -5
- package/index.ts +19 -32
- package/package.json +1 -1
- package/storage.ts +5 -165
- package/types.ts +10 -0
- package/viewer.ts +51 -51
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0 - 2026-09-10
|
|
4
|
+
|
|
5
|
+
- Render `/agent-usage` directly in the normal TUI transcript through Pi's TUI-only custom-entry API instead of opening an overlay.
|
|
6
|
+
- Add main-model and sub-agent-model usage breakdowns using the actual response model reported by each assistant call.
|
|
7
|
+
- Show per-model input, output, cache reads/writes, total tokens, cost, contributing Agent-session count, and usage-record count.
|
|
8
|
+
- Keep unattributable tool and summary usage in a separate `Tools/summaries` bucket.
|
|
9
|
+
- Preserve strict separation from LLM context and built-in `/session` cache accounting.
|
|
10
|
+
|
|
11
|
+
## 0.8.1 - 2026-09-10
|
|
12
|
+
|
|
13
|
+
- Remove automatic legacy-file archival and its full main-session scan; the extension no longer archives or deletes user session data.
|
|
14
|
+
- Retain only the lightweight `codex-agents` → `pi-agents` compatibility migration required for users upgrading directly from 0.7.x; it is a no-op for fresh installations.
|
|
15
|
+
|
|
3
16
|
## 0.8.0 - 2026-09-09
|
|
4
17
|
|
|
5
18
|
- Add `/agent-usage`, a read-only overlay that reports separate main/sub-agent token and cache totals plus combined tokens and cost without modifying root context.
|
package/README.md
CHANGED
|
@@ -64,7 +64,9 @@ Pi's built-in `/session` remains the authoritative view of the main Agent and it
|
|
|
64
64
|
/agent-usage
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
The
|
|
67
|
+
The command writes a detailed report directly into the normal TUI transcript, like `/session`; it does not open an overlay. The report includes main, sub-agent, and combined input/output/cache/token/cost totals, followed by separate main-model and sub-agent-model breakdowns. Each model row shows its tokens, prompt/output/cache details, number of contributing Agent sessions, and number of usage records. Non-model tool and summary usage is kept in an explicit `Tools/summaries` bucket rather than being misattributed to a model.
|
|
68
|
+
|
|
69
|
+
The report is stored as a TUI-only custom entry so it remains outside LLM context and does not affect `/session` message or token accounting. Reading the report does not load or wake child AgentSessions. Pi currently has no extension hook that can add child tokens to built-in `/session` while excluding them from that command's cache statistics.
|
|
68
70
|
|
|
69
71
|
## Tools
|
|
70
72
|
|
|
@@ -189,8 +191,8 @@ The settings file is optional, but spawning requires a model from either the tas
|
|
|
189
191
|
- Each root storage group records its owning main-session file in `owner.json`
|
|
190
192
|
- The former `~/.pi/agent/codex-agents/` directory and `agents-setting.json` filename migrate automatically without overwriting newer files
|
|
191
193
|
- Resuming an existing main session removes groups whose owning main-session file has been deleted; new sessions and `/reload` do not trigger grouped cleanup
|
|
192
|
-
- Legacy flat files are archived only after every ordinary Pi main session has been scanned and no persisted agent-state reference exists
|
|
193
194
|
- Referenced legacy flat child files are migrated when their main session is resumed
|
|
195
|
+
- The extension never automatically archives or deletes legacy flat files
|
|
194
196
|
- Parents receive a compact completion notice instead of the full answer; use `list_agents(view="results")` or read the result file on demand
|
|
195
197
|
- Notices to a busy agent are queued safely: `wait_agent` returns them in its own result, and any leftovers are delivered right after a successful recipient turn
|
|
196
198
|
- `wait_agent` sends only newly queued mailbox notices to the model; its child status tree excludes the active caller, is folded in the TUI by default, and can be toggled with `Ctrl+O`
|
package/control.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
type AgentRole,
|
|
48
48
|
type AgentRoleView,
|
|
49
49
|
type AgentTranscriptView,
|
|
50
|
+
type AgentUsageBreakdownEntry,
|
|
50
51
|
type AgentUsageReport,
|
|
51
52
|
type AgentUsageTotals,
|
|
52
53
|
type AgentView,
|
|
@@ -126,23 +127,77 @@ function addUsageTotals(target: AgentUsageTotals, source: AgentUsageTotals): voi
|
|
|
126
127
|
target.cost += source.cost;
|
|
127
128
|
}
|
|
128
129
|
|
|
129
|
-
|
|
130
|
+
interface SessionUsageDetails {
|
|
131
|
+
totals: AgentUsageTotals;
|
|
132
|
+
breakdown: Map<string, AgentUsageBreakdownEntry>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function addAttributedUsage(
|
|
136
|
+
breakdown: Map<string, AgentUsageBreakdownEntry>,
|
|
137
|
+
key: string,
|
|
138
|
+
usage: Usage,
|
|
139
|
+
): void {
|
|
140
|
+
let item = breakdown.get(key);
|
|
141
|
+
if (!item) {
|
|
142
|
+
item = { key, usage: emptyUsageTotals(), sessionCount: 1, operations: 0 };
|
|
143
|
+
breakdown.set(key, item);
|
|
144
|
+
}
|
|
145
|
+
addUsage(item.usage, usage);
|
|
146
|
+
item.operations++;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function sessionUsageDetails(sessionManager: Pick<SessionManager, "getEntries">): SessionUsageDetails {
|
|
130
150
|
const totals = emptyUsageTotals();
|
|
151
|
+
const breakdown = new Map<string, AgentUsageBreakdownEntry>();
|
|
131
152
|
for (const entry of sessionManager.getEntries()) {
|
|
132
153
|
if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
|
|
133
154
|
addUsage(totals, entry.usage);
|
|
155
|
+
addAttributedUsage(breakdown, "Tools/summaries", entry.usage);
|
|
134
156
|
}
|
|
135
157
|
if (entry.type !== "message") continue;
|
|
136
158
|
const message = entry.message;
|
|
137
|
-
if (message.role === "assistant"
|
|
159
|
+
if (message.role === "assistant") {
|
|
160
|
+
addUsage(totals, message.usage);
|
|
161
|
+
const responseModel = (message as typeof message & { responseModel?: string }).responseModel;
|
|
162
|
+
addAttributedUsage(breakdown, `${message.provider}/${responseModel ?? message.model}`, message.usage);
|
|
163
|
+
} else if (message.role === "toolResult" && message.usage) {
|
|
164
|
+
addUsage(totals, message.usage);
|
|
165
|
+
addAttributedUsage(breakdown, "Tools/summaries", message.usage);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { totals, breakdown };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function mergeUsageBreakdown(
|
|
172
|
+
target: Map<string, AgentUsageBreakdownEntry>,
|
|
173
|
+
source: Map<string, AgentUsageBreakdownEntry>,
|
|
174
|
+
): void {
|
|
175
|
+
for (const item of source.values()) {
|
|
176
|
+
let aggregate = target.get(item.key);
|
|
177
|
+
if (!aggregate) {
|
|
178
|
+
aggregate = { key: item.key, usage: emptyUsageTotals(), sessionCount: 0, operations: 0 };
|
|
179
|
+
target.set(item.key, aggregate);
|
|
180
|
+
}
|
|
181
|
+
addUsageTotals(aggregate.usage, item.usage);
|
|
182
|
+
aggregate.sessionCount += item.sessionCount;
|
|
183
|
+
aggregate.operations += item.operations;
|
|
138
184
|
}
|
|
139
|
-
return totals;
|
|
140
185
|
}
|
|
141
186
|
|
|
142
187
|
function normalizeCost(value: number): number {
|
|
143
188
|
return Math.round(value * 1_000_000_000) / 1_000_000_000;
|
|
144
189
|
}
|
|
145
190
|
|
|
191
|
+
function finalizeBreakdown(breakdown: Map<string, AgentUsageBreakdownEntry>): AgentUsageBreakdownEntry[] {
|
|
192
|
+
return [...breakdown.values()]
|
|
193
|
+
.map((item) => ({
|
|
194
|
+
...item,
|
|
195
|
+
usage: { ...item.usage, cost: normalizeCost(item.usage.cost) },
|
|
196
|
+
}))
|
|
197
|
+
.filter((item) => item.usage.total > 0 || item.usage.cost > 0)
|
|
198
|
+
.sort((left, right) => right.usage.total - left.usage.total || left.key.localeCompare(right.key));
|
|
199
|
+
}
|
|
200
|
+
|
|
146
201
|
function normalizeAgentName(name: string): string {
|
|
147
202
|
const normalized = name.trim();
|
|
148
203
|
if (!normalized) throw new Error("task_name must not be empty");
|
|
@@ -1166,8 +1221,10 @@ export class AgentControl {
|
|
|
1166
1221
|
|
|
1167
1222
|
getUsage(ctx: ExtensionContext): AgentUsageReport {
|
|
1168
1223
|
this.callerPath(ctx);
|
|
1169
|
-
const
|
|
1224
|
+
const mainDetails = sessionUsageDetails(ctx.sessionManager);
|
|
1225
|
+
const main = mainDetails.totals;
|
|
1170
1226
|
const subagents = emptyUsageTotals();
|
|
1227
|
+
const subagentBreakdown = new Map<string, AgentUsageBreakdownEntry>();
|
|
1171
1228
|
let unreadableSubagents = 0;
|
|
1172
1229
|
const countedSessionIds = new Set<string>();
|
|
1173
1230
|
for (const record of this.agentsByPath.values()) {
|
|
@@ -1182,7 +1239,9 @@ export class AgentControl {
|
|
|
1182
1239
|
const sessionId = manager.getSessionId();
|
|
1183
1240
|
if (countedSessionIds.has(sessionId)) continue;
|
|
1184
1241
|
countedSessionIds.add(sessionId);
|
|
1185
|
-
|
|
1242
|
+
const details = sessionUsageDetails(manager);
|
|
1243
|
+
addUsageTotals(subagents, details.totals);
|
|
1244
|
+
mergeUsageBreakdown(subagentBreakdown, details.breakdown);
|
|
1186
1245
|
} catch {
|
|
1187
1246
|
unreadableSubagents++;
|
|
1188
1247
|
}
|
|
@@ -1197,6 +1256,8 @@ export class AgentControl {
|
|
|
1197
1256
|
main,
|
|
1198
1257
|
subagents,
|
|
1199
1258
|
combined,
|
|
1259
|
+
mainBreakdown: finalizeBreakdown(mainDetails.breakdown),
|
|
1260
|
+
subagentBreakdown: finalizeBreakdown(subagentBreakdown),
|
|
1200
1261
|
subagentCount: this.agentsByPath.size,
|
|
1201
1262
|
unreadableSubagents,
|
|
1202
1263
|
};
|
package/index.ts
CHANGED
|
@@ -5,9 +5,16 @@ import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
|
5
5
|
import { AgentControl } from "./control.ts";
|
|
6
6
|
import { getAgentSettingsPath, loadAgentSettings, resolveAgentLimits } from "./settings.ts";
|
|
7
7
|
import { createCollaborationTools } from "./tools.ts";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
8
|
+
import { migrateLegacyAgentStorage } from "./storage.ts";
|
|
9
|
+
import {
|
|
10
|
+
EXTENSION_ID,
|
|
11
|
+
ROOT_PATH,
|
|
12
|
+
USAGE_ENTRY_TYPE,
|
|
13
|
+
type AgentLifecycleStatus,
|
|
14
|
+
type AgentUsageReport,
|
|
15
|
+
type AgentView,
|
|
16
|
+
} from "./types.ts";
|
|
17
|
+
import { AgentPickerComponent, AgentTranscriptViewer, formatAgentUsage, renderAgentUsage } from "./viewer.ts";
|
|
11
18
|
|
|
12
19
|
const SELF_PATH = fileURLToPath(import.meta.url);
|
|
13
20
|
const WIDGET_KEY = "codex-agents-tree";
|
|
@@ -101,7 +108,6 @@ export default function codexAgentsExtension(pi: ExtensionAPI): void {
|
|
|
101
108
|
let activeContext: ExtensionContext | undefined;
|
|
102
109
|
let widgetTui: { requestRender(): void } | undefined;
|
|
103
110
|
let storageMigrationReported = false;
|
|
104
|
-
let legacyArchiveStarted = false;
|
|
105
111
|
|
|
106
112
|
const updateUi = () => {
|
|
107
113
|
const ctx = activeContext;
|
|
@@ -135,18 +141,6 @@ export default function codexAgentsExtension(pi: ExtensionAPI): void {
|
|
|
135
141
|
}
|
|
136
142
|
for (const warning of storageMigration.warnings) ctx.ui.notify(`Agent storage migration: ${warning}`, "warning");
|
|
137
143
|
}
|
|
138
|
-
if (!legacyArchiveStarted) {
|
|
139
|
-
legacyArchiveStarted = true;
|
|
140
|
-
void archiveUnownedLegacyFiles().then((report) => {
|
|
141
|
-
const current = activeContext;
|
|
142
|
-
if (!current) return;
|
|
143
|
-
if (report.error) {
|
|
144
|
-
current.ui.notify(`Legacy agent archive skipped: ${report.error}`, "warning");
|
|
145
|
-
} else if (report.archivedFiles > 0) {
|
|
146
|
-
current.ui.notify(`Archived ${report.archivedFiles} unowned legacy agent files to ${report.archiveDirectory}.`, "info");
|
|
147
|
-
}
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
144
|
const resumedExistingSession = event.reason === "resume"
|
|
151
145
|
|| (event.reason === "startup" && ctx.sessionManager.getEntries().some((entry) => entry.type === "message"));
|
|
152
146
|
if (resumedExistingSession) {
|
|
@@ -222,32 +216,25 @@ export default function codexAgentsExtension(pi: ExtensionAPI): void {
|
|
|
222
216
|
return new Text(`${header}\n${theme.fg("customMessageText", body)}`, 1, 0);
|
|
223
217
|
});
|
|
224
218
|
|
|
219
|
+
pi.registerEntryRenderer<AgentUsageReport>(USAGE_ENTRY_TYPE, (entry, _options, theme) => {
|
|
220
|
+
return entry.data ? renderAgentUsage(entry.data, theme) : undefined;
|
|
221
|
+
});
|
|
222
|
+
|
|
225
223
|
pi.registerCommand("agent-usage", {
|
|
226
|
-
description: "Show main
|
|
224
|
+
description: "Show detailed main and sub-agent token usage by model",
|
|
227
225
|
handler: async (_args, ctx) => {
|
|
228
226
|
activeContext = ctx;
|
|
229
|
-
let report;
|
|
227
|
+
let report: AgentUsageReport;
|
|
230
228
|
try {
|
|
231
229
|
report = control.getUsage(ctx);
|
|
232
230
|
} catch (error) {
|
|
233
231
|
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
234
232
|
return;
|
|
235
233
|
}
|
|
236
|
-
if (ctx.mode
|
|
234
|
+
if (ctx.mode === "tui") {
|
|
235
|
+
pi.appendEntry(USAGE_ENTRY_TYPE, report);
|
|
236
|
+
} else {
|
|
237
237
|
ctx.ui.notify(formatAgentUsage(report), "info");
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
const releaseUserOverlay = control.beginUserOverlay();
|
|
241
|
-
try {
|
|
242
|
-
await ctx.ui.custom<void>(
|
|
243
|
-
(_tui, theme, keybindings, done) => new AgentUsageViewer(theme, keybindings, report, done),
|
|
244
|
-
{
|
|
245
|
-
overlay: true,
|
|
246
|
-
overlayOptions: { anchor: "center", width: "62%", maxHeight: "70%", margin: 1 },
|
|
247
|
-
},
|
|
248
|
-
);
|
|
249
|
-
} finally {
|
|
250
|
-
releaseUserOverlay();
|
|
251
238
|
}
|
|
252
239
|
},
|
|
253
240
|
});
|
package/package.json
CHANGED
package/storage.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { createInterface } from "node:readline";
|
|
4
3
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { STATE_ENTRY_TYPE } from "./types.ts";
|
|
6
4
|
|
|
7
5
|
const STORAGE_DIRECTORY_NAME = "pi-agents";
|
|
8
6
|
const LEGACY_STORAGE_DIRECTORY_NAME = "codex-agents";
|
|
@@ -14,14 +12,6 @@ export interface StorageMigrationReport {
|
|
|
14
12
|
warnings: string[];
|
|
15
13
|
}
|
|
16
14
|
|
|
17
|
-
export interface LegacyArchiveReport {
|
|
18
|
-
archivedFiles: number;
|
|
19
|
-
retainedFiles: number;
|
|
20
|
-
scannedMainSessions: number;
|
|
21
|
-
archiveDirectory?: string;
|
|
22
|
-
error?: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
15
|
export function getAgentStorageDirectory(): string {
|
|
26
16
|
return path.join(getAgentDir(), STORAGE_DIRECTORY_NAME);
|
|
27
17
|
}
|
|
@@ -65,7 +55,10 @@ function mergeWithoutOverwrite(source: string, destination: string, report: Stor
|
|
|
65
55
|
}
|
|
66
56
|
}
|
|
67
57
|
|
|
68
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Preserve the 0.7.x upgrade path. For a fresh installation this is only an
|
|
60
|
+
* existence check and performs no writes.
|
|
61
|
+
*/
|
|
69
62
|
export function migrateLegacyAgentStorage(): StorageMigrationReport {
|
|
70
63
|
const report: StorageMigrationReport = { movedEntries: 0, warnings: [] };
|
|
71
64
|
const source = getLegacyAgentStorageDirectory();
|
|
@@ -87,161 +80,8 @@ export function resolveMigratedStoragePath(file: string): string {
|
|
|
87
80
|
const legacyRoot = path.resolve(getLegacyAgentStorageDirectory());
|
|
88
81
|
if (source !== legacyRoot && !source.startsWith(`${legacyRoot}${path.sep}`)) return source;
|
|
89
82
|
// A collision-safe merge leaves the legacy source in place. Prefer the exact
|
|
90
|
-
// persisted path
|
|
91
|
-
// different destination file that happens to share its relative name.
|
|
83
|
+
// persisted path instead of shadowing it with a different destination file.
|
|
92
84
|
if (fs.existsSync(source)) return source;
|
|
93
85
|
const translated = path.join(getAgentStorageDirectory(), path.relative(legacyRoot, source));
|
|
94
86
|
return fs.existsSync(translated) ? translated : source;
|
|
95
87
|
}
|
|
96
|
-
|
|
97
|
-
function listMainSessionFiles(): string[] {
|
|
98
|
-
const sessionsRoot = path.join(getAgentDir(), "sessions");
|
|
99
|
-
if (!fs.existsSync(sessionsRoot)) return [];
|
|
100
|
-
const files: string[] = [];
|
|
101
|
-
for (const project of fs.readdirSync(sessionsRoot, { withFileTypes: true })) {
|
|
102
|
-
if (!project.isDirectory() && !project.isSymbolicLink()) continue;
|
|
103
|
-
const projectDirectory = path.join(sessionsRoot, project.name);
|
|
104
|
-
try {
|
|
105
|
-
for (const entry of fs.readdirSync(projectDirectory, { withFileTypes: true })) {
|
|
106
|
-
if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(path.join(projectDirectory, entry.name));
|
|
107
|
-
}
|
|
108
|
-
} catch {
|
|
109
|
-
// A failed directory scan makes archival unsafe.
|
|
110
|
-
throw new Error(`could not scan main session directory: ${projectDirectory}`);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
return files;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function sessionFileIds(file: string): string[] {
|
|
117
|
-
const ids = new Set<string>();
|
|
118
|
-
const basename = path.basename(file);
|
|
119
|
-
const filenameMatch = basename.match(/_([0-9a-f-]{16,})\.jsonl$/i);
|
|
120
|
-
if (filenameMatch?.[1]) ids.add(filenameMatch[1]);
|
|
121
|
-
let descriptor: number | undefined;
|
|
122
|
-
try {
|
|
123
|
-
descriptor = fs.openSync(file, "r");
|
|
124
|
-
const buffer = Buffer.allocUnsafe(4096);
|
|
125
|
-
const bytesRead = fs.readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
126
|
-
const firstLine = buffer.subarray(0, bytesRead).toString("utf8").split("\n", 1)[0];
|
|
127
|
-
if (firstLine) {
|
|
128
|
-
const header = JSON.parse(firstLine) as { type?: unknown; id?: unknown };
|
|
129
|
-
if (header.type === "session" && typeof header.id === "string") ids.add(header.id);
|
|
130
|
-
}
|
|
131
|
-
} catch {
|
|
132
|
-
// The filename ID remains usable for conservative reference detection.
|
|
133
|
-
} finally {
|
|
134
|
-
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
135
|
-
}
|
|
136
|
-
return [...ids];
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function legacyFlatFiles(): Array<{ file: string; ids: string[] }> {
|
|
140
|
-
const roots = [getAgentStorageDirectory(), getLegacyAgentStorageDirectory()];
|
|
141
|
-
const files: Array<{ file: string; ids: string[] }> = [];
|
|
142
|
-
for (const root of roots) {
|
|
143
|
-
for (const kind of ["sessions", "results"] as const) {
|
|
144
|
-
const directory = path.join(root, kind);
|
|
145
|
-
if (!fs.existsSync(directory)) continue;
|
|
146
|
-
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
147
|
-
if (!entry.isFile()) continue;
|
|
148
|
-
if (kind === "sessions" && !entry.name.endsWith(".jsonl")) continue;
|
|
149
|
-
if (kind === "results" && !entry.name.endsWith(".md")) continue;
|
|
150
|
-
const file = path.join(directory, entry.name);
|
|
151
|
-
const ids = kind === "sessions"
|
|
152
|
-
? sessionFileIds(file)
|
|
153
|
-
: [path.basename(file, path.extname(file))];
|
|
154
|
-
files.push({ file, ids });
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
return files;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function uniqueArchiveTarget(directory: string, basename: string): string {
|
|
162
|
-
let target = path.join(directory, basename);
|
|
163
|
-
let suffix = 2;
|
|
164
|
-
while (fs.existsSync(target)) {
|
|
165
|
-
const extension = path.extname(basename);
|
|
166
|
-
const stem = path.basename(basename, extension);
|
|
167
|
-
target = path.join(directory, `${stem}-${suffix}${extension}`);
|
|
168
|
-
suffix++;
|
|
169
|
-
}
|
|
170
|
-
return target;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Archive legacy flat files only after scanning every ordinary Pi session for a
|
|
175
|
-
* persisted agent-state reference. Any scan failure leaves all candidates in place.
|
|
176
|
-
*/
|
|
177
|
-
export async function archiveUnownedLegacyFiles(): Promise<LegacyArchiveReport> {
|
|
178
|
-
let candidates: Array<{ file: string; ids: string[] }>;
|
|
179
|
-
let mainSessions: string[];
|
|
180
|
-
try {
|
|
181
|
-
candidates = legacyFlatFiles();
|
|
182
|
-
if (candidates.length === 0) return { archivedFiles: 0, retainedFiles: 0, scannedMainSessions: 0 };
|
|
183
|
-
mainSessions = listMainSessionFiles();
|
|
184
|
-
} catch (error) {
|
|
185
|
-
return {
|
|
186
|
-
archivedFiles: 0,
|
|
187
|
-
retainedFiles: 0,
|
|
188
|
-
scannedMainSessions: 0,
|
|
189
|
-
error: error instanceof Error ? error.message : String(error),
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const referencedIds = new Set<string>();
|
|
194
|
-
const candidateIds = new Set(candidates.flatMap((candidate) => candidate.ids));
|
|
195
|
-
let scannedMainSessions = 0;
|
|
196
|
-
try {
|
|
197
|
-
for (const sessionFile of mainSessions) {
|
|
198
|
-
const lines = createInterface({ input: fs.createReadStream(sessionFile, { encoding: "utf8" }), crlfDelay: Infinity });
|
|
199
|
-
for await (const line of lines) {
|
|
200
|
-
if (!line.includes(STATE_ENTRY_TYPE)) continue;
|
|
201
|
-
for (const id of candidateIds) {
|
|
202
|
-
if (line.includes(id)) referencedIds.add(id);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
scannedMainSessions++;
|
|
206
|
-
}
|
|
207
|
-
} catch (error) {
|
|
208
|
-
return {
|
|
209
|
-
archivedFiles: 0,
|
|
210
|
-
retainedFiles: candidates.length,
|
|
211
|
-
scannedMainSessions,
|
|
212
|
-
error: error instanceof Error ? error.message : String(error),
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// An unidentifiable file cannot be proven unowned and must remain untouched.
|
|
217
|
-
const unowned = candidates.filter((candidate) => candidate.ids.length > 0 && !candidate.ids.some((id) => referencedIds.has(id)));
|
|
218
|
-
if (unowned.length === 0) {
|
|
219
|
-
return { archivedFiles: 0, retainedFiles: candidates.length, scannedMainSessions };
|
|
220
|
-
}
|
|
221
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
222
|
-
const archiveDirectory = path.join(getAgentStorageDirectory(), "archive", "legacy-unowned", stamp);
|
|
223
|
-
let archivedFiles = 0;
|
|
224
|
-
try {
|
|
225
|
-
for (const candidate of unowned) {
|
|
226
|
-
const kind = candidate.file.endsWith(".jsonl") ? "sessions" : "results";
|
|
227
|
-
const destinationDirectory = path.join(archiveDirectory, kind);
|
|
228
|
-
fs.mkdirSync(destinationDirectory, { recursive: true });
|
|
229
|
-
fs.renameSync(candidate.file, uniqueArchiveTarget(destinationDirectory, path.basename(candidate.file)));
|
|
230
|
-
archivedFiles++;
|
|
231
|
-
}
|
|
232
|
-
} catch (error) {
|
|
233
|
-
return {
|
|
234
|
-
archivedFiles,
|
|
235
|
-
retainedFiles: candidates.length - archivedFiles,
|
|
236
|
-
scannedMainSessions,
|
|
237
|
-
archiveDirectory,
|
|
238
|
-
error: error instanceof Error ? error.message : String(error),
|
|
239
|
-
};
|
|
240
|
-
}
|
|
241
|
-
return {
|
|
242
|
-
archivedFiles,
|
|
243
|
-
retainedFiles: candidates.length - archivedFiles,
|
|
244
|
-
scannedMainSessions,
|
|
245
|
-
archiveDirectory,
|
|
246
|
-
};
|
|
247
|
-
}
|
package/types.ts
CHANGED
|
@@ -6,6 +6,7 @@ export const EXTENSION_ID = "codex-agents";
|
|
|
6
6
|
export const STATE_ENTRY_TYPE = "codex-agents-state";
|
|
7
7
|
export const CHILD_META_ENTRY_TYPE = "codex-agents-child-meta";
|
|
8
8
|
export const FORK_CONTEXT_ENTRY_TYPE = "codex-agents-fork-context";
|
|
9
|
+
export const USAGE_ENTRY_TYPE = "pi-agents-usage";
|
|
9
10
|
export const ROOT_PATH = "/root";
|
|
10
11
|
export const DIRECT_AGENT_TOOL_NAMES = [
|
|
11
12
|
"spawn_agents",
|
|
@@ -113,10 +114,19 @@ export interface AgentUsageTotals {
|
|
|
113
114
|
cost: number;
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
export interface AgentUsageBreakdownEntry {
|
|
118
|
+
key: string;
|
|
119
|
+
usage: AgentUsageTotals;
|
|
120
|
+
sessionCount: number;
|
|
121
|
+
operations: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
116
124
|
export interface AgentUsageReport {
|
|
117
125
|
main: AgentUsageTotals;
|
|
118
126
|
subagents: AgentUsageTotals;
|
|
119
127
|
combined: AgentUsageTotals;
|
|
128
|
+
mainBreakdown: AgentUsageBreakdownEntry[];
|
|
129
|
+
subagentBreakdown: AgentUsageBreakdownEntry[];
|
|
120
130
|
subagentCount: number;
|
|
121
131
|
unreadableSubagents: number;
|
|
122
132
|
}
|
package/viewer.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import {
|
|
21
21
|
ROOT_PATH,
|
|
22
22
|
type AgentTranscriptView,
|
|
23
|
+
type AgentUsageBreakdownEntry,
|
|
23
24
|
type AgentUsageReport,
|
|
24
25
|
type AgentUsageTotals,
|
|
25
26
|
type AgentView,
|
|
@@ -88,66 +89,65 @@ function usageCacheRate(usage: AgentUsageTotals): string {
|
|
|
88
89
|
return prompt > 0 ? `${((usage.cacheRead / prompt) * 100).toFixed(1)}%` : "n/a";
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
function usageLine(label: string, usage: AgentUsageTotals): string {
|
|
93
|
+
const parts = [
|
|
94
|
+
`${label}: ${usage.total.toLocaleString()} tokens`,
|
|
95
|
+
`input ${usagePromptTokens(usage).toLocaleString()}`,
|
|
96
|
+
`output ${usage.output.toLocaleString()}`,
|
|
97
|
+
`cached ${usage.cacheRead.toLocaleString()} (${usageCacheRate(usage)})`,
|
|
98
|
+
];
|
|
99
|
+
if (usage.cacheWrite > 0) parts.push(`cache write ${usage.cacheWrite.toLocaleString()}`);
|
|
100
|
+
if (usage.cost > 0) parts.push(`$${usage.cost.toFixed(3)}`);
|
|
101
|
+
return parts.join(" · ");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function breakdownLines(
|
|
105
|
+
title: string,
|
|
106
|
+
items: AgentUsageBreakdownEntry[],
|
|
107
|
+
sessionLabel: string,
|
|
108
|
+
): string[] {
|
|
109
|
+
const lines = [title];
|
|
110
|
+
if (items.length === 0) {
|
|
111
|
+
lines.push(" (no billed usage)");
|
|
112
|
+
return lines;
|
|
113
|
+
}
|
|
114
|
+
for (const item of items) {
|
|
115
|
+
const sessions = `${item.sessionCount.toLocaleString()} ${sessionLabel}${item.sessionCount === 1 ? "" : "s"}`;
|
|
116
|
+
const operations = `${item.operations.toLocaleString()} usage record${item.operations === 1 ? "" : "s"}`;
|
|
117
|
+
lines.push(` ${item.key}`);
|
|
118
|
+
lines.push(` ${usageLine("Usage", item.usage)} · ${sessions} · ${operations}`);
|
|
119
|
+
}
|
|
120
|
+
return lines;
|
|
121
|
+
}
|
|
122
|
+
|
|
91
123
|
export function formatAgentUsage(report: AgentUsageReport): string {
|
|
124
|
+
const counted = report.subagentCount - report.unreadableSubagents;
|
|
92
125
|
const lines = [
|
|
93
126
|
"Agent Usage",
|
|
94
127
|
"",
|
|
95
|
-
|
|
96
|
-
`
|
|
97
|
-
`
|
|
128
|
+
"Totals",
|
|
129
|
+
` ${usageLine("Main agent", report.main)}`,
|
|
130
|
+
` ${usageLine("Sub-agents", report.subagents)}`,
|
|
131
|
+
` ${usageLine("Combined", report.combined)}`,
|
|
132
|
+
` Sub-agent sessions: ${counted.toLocaleString()}/${report.subagentCount.toLocaleString()} readable`,
|
|
133
|
+
"",
|
|
134
|
+
...breakdownLines("Main-agent models", report.mainBreakdown, "session"),
|
|
98
135
|
"",
|
|
99
|
-
|
|
136
|
+
...breakdownLines("Sub-agent models", report.subagentBreakdown, "agent"),
|
|
100
137
|
];
|
|
101
|
-
if (report.
|
|
102
|
-
if (report.unreadableSubagents > 0) lines.push(`Unreadable sessions: ${report.unreadableSubagents}`);
|
|
138
|
+
if (report.unreadableSubagents > 0) lines.push("", `Unreadable sub-agent sessions: ${report.unreadableSubagents.toLocaleString()}`);
|
|
103
139
|
return lines.join("\n");
|
|
104
140
|
}
|
|
105
141
|
|
|
106
|
-
export
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (this.keybindings.matches(data, "tui.select.cancel") || this.keybindings.matches(data, "tui.select.confirm")) {
|
|
116
|
-
this.done();
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
render(width: number): string[] {
|
|
121
|
-
const innerWidth = Math.max(1, width - 2);
|
|
122
|
-
const row = (label: string, usage: AgentUsageTotals, includeCache: boolean): string => {
|
|
123
|
-
const cache = includeCache ? ` · cache ${usageCacheRate(usage)}` : "";
|
|
124
|
-
return ` ${this.theme.fg("dim", `${label}:`)} ${usage.total.toLocaleString()} tokens${cache}`;
|
|
125
|
-
};
|
|
126
|
-
const lines = [
|
|
127
|
-
framedRule(this.theme, innerWidth, "╭", "╮"),
|
|
128
|
-
framedRow(this.theme, ` ${this.theme.fg("accent", this.theme.bold("Agent Usage"))}`, innerWidth),
|
|
129
|
-
framedRule(this.theme, innerWidth, "├", "┤"),
|
|
130
|
-
framedRow(this.theme, row("Main agent", this.report.main, true), innerWidth),
|
|
131
|
-
framedRow(this.theme, row("Sub-agents", this.report.subagents, true), innerWidth),
|
|
132
|
-
framedRow(this.theme, row("Combined", this.report.combined, false), innerWidth),
|
|
133
|
-
framedRow(this.theme, "", innerWidth),
|
|
134
|
-
framedRow(this.theme, ` ${this.theme.fg("dim", "Sub-agents counted:")} ${this.report.subagentCount - this.report.unreadableSubagents}/${this.report.subagentCount}`, innerWidth),
|
|
135
|
-
];
|
|
136
|
-
if (this.report.combined.cost > 0) {
|
|
137
|
-
lines.push(framedRow(this.theme, ` ${this.theme.fg("dim", "Combined cost:")} $${this.report.combined.cost.toFixed(3)}`, innerWidth));
|
|
138
|
-
}
|
|
139
|
-
if (this.report.unreadableSubagents > 0) {
|
|
140
|
-
lines.push(framedRow(this.theme, ` ${this.theme.fg("warning", `Unreadable sessions: ${this.report.unreadableSubagents}`)}`, innerWidth));
|
|
141
|
-
}
|
|
142
|
-
lines.push(
|
|
143
|
-
framedRule(this.theme, innerWidth, "├", "┤"),
|
|
144
|
-
framedRow(this.theme, ` ${this.theme.fg("dim", "Enter / Esc close")}`, innerWidth),
|
|
145
|
-
framedRule(this.theme, innerWidth, "╰", "╯"),
|
|
146
|
-
);
|
|
147
|
-
return lines;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
invalidate(): void {}
|
|
142
|
+
export function renderAgentUsage(report: AgentUsageReport, theme: Theme): Text {
|
|
143
|
+
const headings = new Set(["Agent Usage", "Totals", "Main-agent models", "Sub-agent models"]);
|
|
144
|
+
const rendered = formatAgentUsage(report)
|
|
145
|
+
.split("\n")
|
|
146
|
+
.map((line) => headings.has(line)
|
|
147
|
+
? (line === "Agent Usage" ? theme.bold(line) : theme.bold(theme.fg("accent", line)))
|
|
148
|
+
: line.startsWith("Unreadable ") ? theme.fg("warning", line) : line)
|
|
149
|
+
.join("\n");
|
|
150
|
+
return new Text(rendered, 1, 0);
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
export class AgentPickerComponent {
|