@youngjurry/pi-agents 0.7.5 → 0.8.1
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 +14 -0
- package/README.md +17 -4
- package/control.ts +137 -11
- package/index.ts +41 -1
- package/package.json +1 -1
- package/settings.ts +16 -9
- package/storage.ts +87 -0
- package/types.ts +18 -0
- package/viewer.ts +78 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.1 - 2026-09-10
|
|
4
|
+
|
|
5
|
+
- Remove automatic legacy-file archival and its full main-session scan; the extension no longer archives or deletes user session data.
|
|
6
|
+
- 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.
|
|
7
|
+
|
|
8
|
+
## 0.8.0 - 2026-09-09
|
|
9
|
+
|
|
10
|
+
- 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.
|
|
11
|
+
- Move package-owned storage from `~/.pi/agent/codex-agents/` to `~/.pi/agent/pi-agents/` and rename `agents-setting.json` to `settings.json` with collision-safe automatic migration.
|
|
12
|
+
- Archive legacy flat session/result files only after scanning every ordinary Pi main session and confirming that no persisted agent state references their IDs.
|
|
13
|
+
- Durably initialize queued child JSONL files before returning from batch spawn, preserving the original Session ID, child ownership metadata, and fork context across queueing and process restarts.
|
|
14
|
+
- Repair missing queued-session files from affected older releases with their persisted IDs; already-lost historical fork context cannot be reconstructed.
|
|
15
|
+
- Record the owning root Session ID directly in newly created child metadata.
|
|
16
|
+
|
|
3
17
|
## 0.7.5 - 2026-09-05
|
|
4
18
|
|
|
5
19
|
- Exclude the active `wait_agent` caller from its folded child-status summary.
|
package/README.md
CHANGED
|
@@ -56,6 +56,16 @@ Viewer controls:
|
|
|
56
56
|
|
|
57
57
|
This inspector is implemented only as a slash command and TUI overlay. It does not register an LLM tool, alter tool schemas or system prompts, add messages to the root context, switch sessions, wake agents, or expose child sessions through the normal `/resume` picker.
|
|
58
58
|
|
|
59
|
+
## Usage accounting
|
|
60
|
+
|
|
61
|
+
Pi's built-in `/session` remains the authoritative view of the main Agent and its cache behavior. Use the user-only command below for separately calculated main, sub-agent, and combined totals:
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
/agent-usage
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The read-only overlay reports main and child token totals, separate cache hit rates, combined tokens/cost, and any unreadable child sessions. It reads persisted child JSONL files without loading or waking their AgentSessions, does not add a main-session message, and does not alter provider-facing context. 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
|
+
|
|
59
69
|
## Tools
|
|
60
70
|
|
|
61
71
|
Only two compact collaboration schemas remain active:
|
|
@@ -140,7 +150,7 @@ Review carefully and return findings with exact paths.
|
|
|
140
150
|
|
|
141
151
|
Global sub-agent settings live outside the installed package so updates cannot overwrite them:
|
|
142
152
|
|
|
143
|
-
`~/.pi/agent/
|
|
153
|
+
`~/.pi/agent/pi-agents/settings.json`
|
|
144
154
|
|
|
145
155
|
```json
|
|
146
156
|
{
|
|
@@ -170,14 +180,17 @@ The settings file is optional, but spawning requires a model from either the tas
|
|
|
170
180
|
- Default resident child sessions: 3, configurable with `maxResidentSubagents`
|
|
171
181
|
- `spawn_agents` starts tasks until all execution slots are occupied and records the remainder as `queued`
|
|
172
182
|
- Queued tasks are lightweight, persistent, FIFO ordered, and do not occupy resident-session capacity
|
|
183
|
+
- Queued session identity, child ownership metadata, and sanitized fork context are durably written before the batch spawn returns
|
|
173
184
|
- `list_agents(view="status")` reports each waiting task's queue position plus current running/queued capacity
|
|
174
185
|
- The live widget shows `Agents active: <running>/<limit> · queued: <waiting>` and labels waiting paths explicitly
|
|
175
186
|
- Completed/interrupted sessions are unloaded by LRU when residency is full
|
|
176
|
-
- Child sessions persist under `~/.pi/agent/
|
|
177
|
-
- Full final answers persist under `~/.pi/agent/
|
|
187
|
+
- Child sessions persist under `~/.pi/agent/pi-agents/roots/<root-session-id>/sessions/` and reload lazily
|
|
188
|
+
- Full final answers persist under `~/.pi/agent/pi-agents/roots/<root-session-id>/results/`
|
|
178
189
|
- Each root storage group records its owning main-session file in `owner.json`
|
|
179
|
-
-
|
|
190
|
+
- The former `~/.pi/agent/codex-agents/` directory and `agents-setting.json` filename migrate automatically without overwriting newer files
|
|
191
|
+
- Resuming an existing main session removes groups whose owning main-session file has been deleted; new sessions and `/reload` do not trigger grouped cleanup
|
|
180
192
|
- Referenced legacy flat child files are migrated when their main session is resumed
|
|
193
|
+
- The extension never automatically archives or deletes legacy flat files
|
|
181
194
|
- Parents receive a compact completion notice instead of the full answer; use `list_agents(view="results")` or read the result file on demand
|
|
182
195
|
- 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
|
|
183
196
|
- `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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
4
|
-
import type { Model } from "@earendil-works/pi-ai";
|
|
4
|
+
import type { Model, Usage } from "@earendil-works/pi-ai";
|
|
5
5
|
import {
|
|
6
6
|
buildSessionContext,
|
|
7
7
|
createAgentSession,
|
|
@@ -47,12 +47,15 @@ import {
|
|
|
47
47
|
type AgentRole,
|
|
48
48
|
type AgentRoleView,
|
|
49
49
|
type AgentTranscriptView,
|
|
50
|
+
type AgentUsageReport,
|
|
51
|
+
type AgentUsageTotals,
|
|
50
52
|
type AgentView,
|
|
51
53
|
type ForkContextPayload,
|
|
52
54
|
type PersistedAgent,
|
|
53
55
|
type PersistedTreeState,
|
|
54
56
|
type RootBinding,
|
|
55
57
|
} from "./types.ts";
|
|
58
|
+
import { getAgentStorageDirectory, resolveMigratedStoragePath } from "./storage.ts";
|
|
56
59
|
|
|
57
60
|
const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
|
|
58
61
|
const MIN_WAIT_TIMEOUT_MS = 10_000;
|
|
@@ -100,6 +103,46 @@ interface RootStorageOwner {
|
|
|
100
103
|
rootSessionFile?: string;
|
|
101
104
|
}
|
|
102
105
|
|
|
106
|
+
function emptyUsageTotals(): AgentUsageTotals {
|
|
107
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, cost: 0 };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function addUsage(target: AgentUsageTotals, usage: Usage | undefined): void {
|
|
111
|
+
if (!usage) return;
|
|
112
|
+
target.input += usage.input || 0;
|
|
113
|
+
target.output += usage.output || 0;
|
|
114
|
+
target.cacheRead += usage.cacheRead || 0;
|
|
115
|
+
target.cacheWrite += usage.cacheWrite || 0;
|
|
116
|
+
target.total = target.input + target.output + target.cacheRead + target.cacheWrite;
|
|
117
|
+
target.cost += usage.cost?.total || 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function addUsageTotals(target: AgentUsageTotals, source: AgentUsageTotals): void {
|
|
121
|
+
target.input += source.input;
|
|
122
|
+
target.output += source.output;
|
|
123
|
+
target.cacheRead += source.cacheRead;
|
|
124
|
+
target.cacheWrite += source.cacheWrite;
|
|
125
|
+
target.total = target.input + target.output + target.cacheRead + target.cacheWrite;
|
|
126
|
+
target.cost += source.cost;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function sessionUsage(sessionManager: Pick<SessionManager, "getEntries">): AgentUsageTotals {
|
|
130
|
+
const totals = emptyUsageTotals();
|
|
131
|
+
for (const entry of sessionManager.getEntries()) {
|
|
132
|
+
if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
|
|
133
|
+
addUsage(totals, entry.usage);
|
|
134
|
+
}
|
|
135
|
+
if (entry.type !== "message") continue;
|
|
136
|
+
const message = entry.message;
|
|
137
|
+
if (message.role === "assistant" || message.role === "toolResult") addUsage(totals, message.usage);
|
|
138
|
+
}
|
|
139
|
+
return totals;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function normalizeCost(value: number): number {
|
|
143
|
+
return Math.round(value * 1_000_000_000) / 1_000_000_000;
|
|
144
|
+
}
|
|
145
|
+
|
|
103
146
|
function normalizeAgentName(name: string): string {
|
|
104
147
|
const normalized = name.trim();
|
|
105
148
|
if (!normalized) throw new Error("task_name must not be empty");
|
|
@@ -170,9 +213,9 @@ export class AgentControl {
|
|
|
170
213
|
private uiDialogTail: Promise<void> = Promise.resolve();
|
|
171
214
|
private userOverlayDepth = 0;
|
|
172
215
|
private readonly userOverlayWaiters = new Set<() => void>();
|
|
173
|
-
private readonly rootStorageDirectory = path.join(
|
|
174
|
-
private childSessionDirectory = path.join(
|
|
175
|
-
private agentResultDirectory = path.join(
|
|
216
|
+
private readonly rootStorageDirectory = path.join(getAgentStorageDirectory(), "roots");
|
|
217
|
+
private childSessionDirectory = path.join(getAgentStorageDirectory(), "sessions");
|
|
218
|
+
private agentResultDirectory = path.join(getAgentStorageDirectory(), "results");
|
|
176
219
|
|
|
177
220
|
constructor(
|
|
178
221
|
private readonly pi: ExtensionAPI,
|
|
@@ -268,9 +311,11 @@ export class AgentControl {
|
|
|
268
311
|
|
|
269
312
|
private migrateStoredFile(file: string | undefined, directory: string): { path: string | undefined; migrated: boolean } {
|
|
270
313
|
if (!file) return { path: undefined, migrated: false };
|
|
271
|
-
const
|
|
314
|
+
const original = path.resolve(file);
|
|
315
|
+
const source = resolveMigratedStoragePath(original);
|
|
316
|
+
const translated = source !== original;
|
|
272
317
|
const destinationDirectory = path.resolve(directory);
|
|
273
|
-
if (path.dirname(source) === destinationDirectory) return { path: source, migrated:
|
|
318
|
+
if (path.dirname(source) === destinationDirectory) return { path: source, migrated: translated };
|
|
274
319
|
const target = path.join(destinationDirectory, path.basename(source));
|
|
275
320
|
try {
|
|
276
321
|
fs.mkdirSync(destinationDirectory, { recursive: true });
|
|
@@ -326,7 +371,8 @@ export class AgentControl {
|
|
|
326
371
|
}
|
|
327
372
|
if (owner.version !== 1 || typeof owner.rootSessionId !== "string") continue;
|
|
328
373
|
if (owner.rootSessionId === currentSessionId) continue;
|
|
329
|
-
|
|
374
|
+
// Missing ownership evidence is not proof of an orphan.
|
|
375
|
+
if (!owner.rootSessionFile || fs.existsSync(owner.rootSessionFile)) continue;
|
|
330
376
|
try {
|
|
331
377
|
fs.rmSync(directory, { recursive: true, force: true });
|
|
332
378
|
removed++;
|
|
@@ -547,7 +593,7 @@ export class AgentControl {
|
|
|
547
593
|
private async resolveModel(ctx: ExtensionContext, requested?: string): Promise<Model<any>> {
|
|
548
594
|
const value = requested?.trim();
|
|
549
595
|
if (!value) {
|
|
550
|
-
throw new Error("no sub-agent model configured; set a task model, Role model, or defaultModel in agents
|
|
596
|
+
throw new Error("no sub-agent model configured; set a task model, Role model, or defaultModel in pi-agents/settings.json");
|
|
551
597
|
}
|
|
552
598
|
const runtime = await this.getModelRuntime(ctx);
|
|
553
599
|
const slash = value.indexOf("/");
|
|
@@ -823,6 +869,26 @@ export class AgentControl {
|
|
|
823
869
|
return prepared;
|
|
824
870
|
}
|
|
825
871
|
|
|
872
|
+
private persistQueuedSession(sessionManager: SessionManager): string {
|
|
873
|
+
const sessionFile = sessionManager.getSessionFile();
|
|
874
|
+
const header = sessionManager.getHeader();
|
|
875
|
+
if (!sessionFile || !header) throw new Error("failed to initialize a persisted sub-agent session");
|
|
876
|
+
fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
|
|
877
|
+
const entries = [header, ...sessionManager.getEntries()];
|
|
878
|
+
const descriptor = fs.openSync(sessionFile, "wx");
|
|
879
|
+
let completed = false;
|
|
880
|
+
try {
|
|
881
|
+
fs.writeFileSync(descriptor, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8");
|
|
882
|
+
fs.fsyncSync(descriptor);
|
|
883
|
+
completed = true;
|
|
884
|
+
} finally {
|
|
885
|
+
try { fs.closeSync(descriptor); } finally {
|
|
886
|
+
if (!completed) fs.rmSync(sessionFile, { force: true });
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
return sessionFile;
|
|
890
|
+
}
|
|
891
|
+
|
|
826
892
|
private materializeQueuedBatch(ctx: ExtensionContext, prepared: PreparedSpawn[]): AgentRecord[] {
|
|
827
893
|
const records: AgentRecord[] = [];
|
|
828
894
|
const baseTime = Date.now();
|
|
@@ -858,15 +924,15 @@ export class AgentControl {
|
|
|
858
924
|
payload: item.request.message,
|
|
859
925
|
}),
|
|
860
926
|
};
|
|
861
|
-
record.sessionFile = sessionManager.getSessionFile();
|
|
862
927
|
records.push(record);
|
|
863
|
-
if (!record.sessionFile) throw new Error(`failed to create persisted session for ${record.path}`);
|
|
864
928
|
sessionManager.appendCustomEntry(CHILD_META_ENTRY_TYPE, {
|
|
865
929
|
path: record.path,
|
|
866
930
|
parentPath: record.parentPath,
|
|
931
|
+
rootSessionId: this.root!.sessionId,
|
|
867
932
|
role: record.role,
|
|
868
933
|
});
|
|
869
934
|
sessionManager.appendCustomEntry(FORK_CONTEXT_ENTRY_TYPE, { messages: item.forkMessages });
|
|
935
|
+
record.sessionFile = this.persistQueuedSession(sessionManager);
|
|
870
936
|
}
|
|
871
937
|
} catch (error) {
|
|
872
938
|
for (const record of records) {
|
|
@@ -1098,6 +1164,44 @@ export class AgentControl {
|
|
|
1098
1164
|
return this.view(record);
|
|
1099
1165
|
}
|
|
1100
1166
|
|
|
1167
|
+
getUsage(ctx: ExtensionContext): AgentUsageReport {
|
|
1168
|
+
this.callerPath(ctx);
|
|
1169
|
+
const main = sessionUsage(ctx.sessionManager);
|
|
1170
|
+
const subagents = emptyUsageTotals();
|
|
1171
|
+
let unreadableSubagents = 0;
|
|
1172
|
+
const countedSessionIds = new Set<string>();
|
|
1173
|
+
for (const record of this.agentsByPath.values()) {
|
|
1174
|
+
try {
|
|
1175
|
+
const liveManager = record.session?.sessionManager;
|
|
1176
|
+
const storedFile = record.sessionFile ? resolveMigratedStoragePath(record.sessionFile) : undefined;
|
|
1177
|
+
if (!liveManager && (!storedFile || !fs.existsSync(storedFile))) {
|
|
1178
|
+
unreadableSubagents++;
|
|
1179
|
+
continue;
|
|
1180
|
+
}
|
|
1181
|
+
const manager = liveManager ?? SessionManager.open(storedFile!);
|
|
1182
|
+
const sessionId = manager.getSessionId();
|
|
1183
|
+
if (countedSessionIds.has(sessionId)) continue;
|
|
1184
|
+
countedSessionIds.add(sessionId);
|
|
1185
|
+
addUsageTotals(subagents, sessionUsage(manager));
|
|
1186
|
+
} catch {
|
|
1187
|
+
unreadableSubagents++;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
const combined = emptyUsageTotals();
|
|
1191
|
+
addUsageTotals(combined, main);
|
|
1192
|
+
addUsageTotals(combined, subagents);
|
|
1193
|
+
main.cost = normalizeCost(main.cost);
|
|
1194
|
+
subagents.cost = normalizeCost(subagents.cost);
|
|
1195
|
+
combined.cost = normalizeCost(combined.cost);
|
|
1196
|
+
return {
|
|
1197
|
+
main,
|
|
1198
|
+
subagents,
|
|
1199
|
+
combined,
|
|
1200
|
+
subagentCount: this.agentsByPath.size,
|
|
1201
|
+
unreadableSubagents,
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1101
1205
|
list(ctx: ExtensionContext, prefix?: string, includeResults = false): AgentView[] {
|
|
1102
1206
|
const callerPath = this.callerPath(ctx);
|
|
1103
1207
|
const resolvedPrefix = prefix?.trim() ? this.resolveReference(callerPath, prefix) : undefined;
|
|
@@ -1284,7 +1388,29 @@ export class AgentControl {
|
|
|
1284
1388
|
if (!record.sessionFile) throw new Error(`agent ${record.path} has no persisted session file`);
|
|
1285
1389
|
if (!this.root) throw new Error("root session is not bound");
|
|
1286
1390
|
await this.evictForResidency(record.path);
|
|
1287
|
-
const
|
|
1391
|
+
const migratedSessionFile = resolveMigratedStoragePath(record.sessionFile);
|
|
1392
|
+
if (migratedSessionFile !== record.sessionFile) {
|
|
1393
|
+
record.sessionFile = migratedSessionFile;
|
|
1394
|
+
this.persistState();
|
|
1395
|
+
}
|
|
1396
|
+
let sessionManager: SessionManager;
|
|
1397
|
+
if (fs.existsSync(record.sessionFile)) {
|
|
1398
|
+
sessionManager = SessionManager.open(record.sessionFile);
|
|
1399
|
+
} else {
|
|
1400
|
+
// Older queue releases persisted only a future path. Recreate a durable
|
|
1401
|
+
// session with the recorded identity; its lost fork context is unrecoverable.
|
|
1402
|
+
sessionManager = SessionManager.create(this.root.cwd, this.childSessionDirectory, { id: record.id });
|
|
1403
|
+
sessionManager.appendCustomEntry(CHILD_META_ENTRY_TYPE, {
|
|
1404
|
+
path: record.path,
|
|
1405
|
+
parentPath: record.parentPath,
|
|
1406
|
+
rootSessionId: this.root.sessionId,
|
|
1407
|
+
role: record.role,
|
|
1408
|
+
});
|
|
1409
|
+
sessionManager.appendCustomEntry(FORK_CONTEXT_ENTRY_TYPE, { messages: [] });
|
|
1410
|
+
record.sessionFile = this.persistQueuedSession(sessionManager);
|
|
1411
|
+
this.persistState();
|
|
1412
|
+
sessionManager = SessionManager.open(record.sessionFile);
|
|
1413
|
+
}
|
|
1288
1414
|
const forkContext = this.forkContextFromSessionManager(sessionManager);
|
|
1289
1415
|
const role = resolveRole(this.root.cwd, this.root.ctx.isProjectTrusted(), record.role);
|
|
1290
1416
|
const settingsManager = SettingsManager.create(this.root.cwd, getAgentDir());
|
package/index.ts
CHANGED
|
@@ -5,8 +5,9 @@ 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 { migrateLegacyAgentStorage } from "./storage.ts";
|
|
8
9
|
import { EXTENSION_ID, ROOT_PATH, type AgentLifecycleStatus, type AgentView } from "./types.ts";
|
|
9
|
-
import { AgentPickerComponent, AgentTranscriptViewer } from "./viewer.ts";
|
|
10
|
+
import { AgentPickerComponent, AgentTranscriptViewer, AgentUsageViewer, formatAgentUsage } from "./viewer.ts";
|
|
10
11
|
|
|
11
12
|
const SELF_PATH = fileURLToPath(import.meta.url);
|
|
12
13
|
const WIDGET_KEY = "codex-agents-tree";
|
|
@@ -85,6 +86,7 @@ class AgentTreeWidget {
|
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
export default function codexAgentsExtension(pi: ExtensionAPI): void {
|
|
89
|
+
const storageMigration = migrateLegacyAgentStorage();
|
|
88
90
|
const limits = resolveAgentLimits(loadAgentSettings(), getAgentSettingsPath());
|
|
89
91
|
const control = new AgentControl(
|
|
90
92
|
pi,
|
|
@@ -98,6 +100,7 @@ export default function codexAgentsExtension(pi: ExtensionAPI): void {
|
|
|
98
100
|
|
|
99
101
|
let activeContext: ExtensionContext | undefined;
|
|
100
102
|
let widgetTui: { requestRender(): void } | undefined;
|
|
103
|
+
let storageMigrationReported = false;
|
|
101
104
|
|
|
102
105
|
const updateUi = () => {
|
|
103
106
|
const ctx = activeContext;
|
|
@@ -124,6 +127,13 @@ export default function codexAgentsExtension(pi: ExtensionAPI): void {
|
|
|
124
127
|
pi.on("session_start", (event, ctx) => {
|
|
125
128
|
activeContext = ctx;
|
|
126
129
|
control.bindRoot(ctx);
|
|
130
|
+
if (!storageMigrationReported) {
|
|
131
|
+
storageMigrationReported = true;
|
|
132
|
+
if (storageMigration.movedEntries > 0) {
|
|
133
|
+
ctx.ui.notify(`Migrated agent storage to ~/.pi/agent/pi-agents (${storageMigration.movedEntries} entries).`, "info");
|
|
134
|
+
}
|
|
135
|
+
for (const warning of storageMigration.warnings) ctx.ui.notify(`Agent storage migration: ${warning}`, "warning");
|
|
136
|
+
}
|
|
127
137
|
const resumedExistingSession = event.reason === "resume"
|
|
128
138
|
|| (event.reason === "startup" && ctx.sessionManager.getEntries().some((entry) => entry.type === "message"));
|
|
129
139
|
if (resumedExistingSession) {
|
|
@@ -199,6 +209,36 @@ export default function codexAgentsExtension(pi: ExtensionAPI): void {
|
|
|
199
209
|
return new Text(`${header}\n${theme.fg("customMessageText", body)}`, 1, 0);
|
|
200
210
|
});
|
|
201
211
|
|
|
212
|
+
pi.registerCommand("agent-usage", {
|
|
213
|
+
description: "Show main, sub-agent, and combined token usage",
|
|
214
|
+
handler: async (_args, ctx) => {
|
|
215
|
+
activeContext = ctx;
|
|
216
|
+
let report;
|
|
217
|
+
try {
|
|
218
|
+
report = control.getUsage(ctx);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (ctx.mode !== "tui") {
|
|
224
|
+
ctx.ui.notify(formatAgentUsage(report), "info");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const releaseUserOverlay = control.beginUserOverlay();
|
|
228
|
+
try {
|
|
229
|
+
await ctx.ui.custom<void>(
|
|
230
|
+
(_tui, theme, keybindings, done) => new AgentUsageViewer(theme, keybindings, report, done),
|
|
231
|
+
{
|
|
232
|
+
overlay: true,
|
|
233
|
+
overlayOptions: { anchor: "center", width: "62%", maxHeight: "70%", margin: 1 },
|
|
234
|
+
},
|
|
235
|
+
);
|
|
236
|
+
} finally {
|
|
237
|
+
releaseUserOverlay();
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
202
242
|
pi.registerCommand("agents", {
|
|
203
243
|
description: "Browse sub-agent status and inspect read-only session transcripts",
|
|
204
244
|
getArgumentCompletions: (prefix) => {
|
package/package.json
CHANGED
package/settings.ts
CHANGED
|
@@ -6,7 +6,10 @@ import {
|
|
|
6
6
|
getSupportedThinkingLevels,
|
|
7
7
|
type Model,
|
|
8
8
|
} from "@earendil-works/pi-ai";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
getAgentSettingsPath as getCurrentAgentSettingsPath,
|
|
11
|
+
getLegacyAgentSettingsPaths,
|
|
12
|
+
} from "./storage.ts";
|
|
10
13
|
|
|
11
14
|
export const DEFAULT_MAX_CONCURRENT_SUBAGENTS = 3;
|
|
12
15
|
export const DEFAULT_MAX_RESIDENT_SUBAGENTS = 3;
|
|
@@ -26,7 +29,7 @@ export interface AgentLimits {
|
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
export function getAgentSettingsPath(): string {
|
|
29
|
-
return
|
|
32
|
+
return getCurrentAgentSettingsPath();
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
export function selectAgentModel(...candidates: Array<string | undefined>): string | undefined {
|
|
@@ -53,30 +56,34 @@ export function selectAgentThinkingLevel(
|
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
export function loadAgentSettings(filePath = getAgentSettingsPath()): AgentSettings {
|
|
56
|
-
|
|
59
|
+
let resolvedPath = filePath;
|
|
60
|
+
if (!fs.existsSync(resolvedPath) && path.resolve(filePath) === path.resolve(getAgentSettingsPath())) {
|
|
61
|
+
resolvedPath = getLegacyAgentSettingsPaths().find((candidate) => fs.existsSync(candidate)) ?? resolvedPath;
|
|
62
|
+
}
|
|
63
|
+
if (!fs.existsSync(resolvedPath)) return {};
|
|
57
64
|
|
|
58
65
|
let value: unknown;
|
|
59
66
|
try {
|
|
60
|
-
value = JSON.parse(fs.readFileSync(
|
|
67
|
+
value = JSON.parse(fs.readFileSync(resolvedPath, "utf8"));
|
|
61
68
|
} catch (error) {
|
|
62
69
|
const message = error instanceof Error ? error.message : String(error);
|
|
63
|
-
throw new Error(`failed to read agent settings at ${
|
|
70
|
+
throw new Error(`failed to read agent settings at ${resolvedPath}: ${message}`);
|
|
64
71
|
}
|
|
65
72
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
66
|
-
throw new Error(`agent settings at ${
|
|
73
|
+
throw new Error(`agent settings at ${resolvedPath} must contain a JSON object`);
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
const raw = value as Record<string, unknown>;
|
|
70
77
|
const settings: AgentSettings = {};
|
|
71
78
|
if (raw.defaultModel !== undefined) {
|
|
72
79
|
if (typeof raw.defaultModel !== "string" || !raw.defaultModel.trim()) {
|
|
73
|
-
throw new Error(`defaultModel in ${
|
|
80
|
+
throw new Error(`defaultModel in ${resolvedPath} must be a non-empty provider/model string`);
|
|
74
81
|
}
|
|
75
82
|
settings.defaultModel = raw.defaultModel.trim();
|
|
76
83
|
}
|
|
77
84
|
if (raw.defaultThinkingLevel !== undefined) {
|
|
78
85
|
if (typeof raw.defaultThinkingLevel !== "string" || !CHILD_THINKING_LEVELS.includes(raw.defaultThinkingLevel as ThinkingLevel)) {
|
|
79
|
-
throw new Error(`defaultThinkingLevel in ${
|
|
86
|
+
throw new Error(`defaultThinkingLevel in ${resolvedPath} must be one of: ${CHILD_THINKING_LEVELS.join(", ")}`);
|
|
80
87
|
}
|
|
81
88
|
settings.defaultThinkingLevel = raw.defaultThinkingLevel as ThinkingLevel;
|
|
82
89
|
}
|
|
@@ -84,7 +91,7 @@ export function loadAgentSettings(filePath = getAgentSettingsPath()): AgentSetti
|
|
|
84
91
|
const limit = raw[key];
|
|
85
92
|
if (limit === undefined) continue;
|
|
86
93
|
if (typeof limit !== "number" || !Number.isSafeInteger(limit) || limit < 1) {
|
|
87
|
-
throw new Error(`${key} in ${
|
|
94
|
+
throw new Error(`${key} in ${resolvedPath} must be a positive integer`);
|
|
88
95
|
}
|
|
89
96
|
settings[key] = limit;
|
|
90
97
|
}
|
package/storage.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
const STORAGE_DIRECTORY_NAME = "pi-agents";
|
|
6
|
+
const LEGACY_STORAGE_DIRECTORY_NAME = "codex-agents";
|
|
7
|
+
const SETTINGS_FILE_NAME = "settings.json";
|
|
8
|
+
const LEGACY_SETTINGS_FILE_NAME = "agents-setting.json";
|
|
9
|
+
|
|
10
|
+
export interface StorageMigrationReport {
|
|
11
|
+
movedEntries: number;
|
|
12
|
+
warnings: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getAgentStorageDirectory(): string {
|
|
16
|
+
return path.join(getAgentDir(), STORAGE_DIRECTORY_NAME);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getLegacyAgentStorageDirectory(): string {
|
|
20
|
+
return path.join(getAgentDir(), LEGACY_STORAGE_DIRECTORY_NAME);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getAgentSettingsPath(): string {
|
|
24
|
+
return path.join(getAgentStorageDirectory(), SETTINGS_FILE_NAME);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getLegacyAgentSettingsPaths(): string[] {
|
|
28
|
+
return [
|
|
29
|
+
path.join(getAgentStorageDirectory(), LEGACY_SETTINGS_FILE_NAME),
|
|
30
|
+
path.join(getLegacyAgentStorageDirectory(), LEGACY_SETTINGS_FILE_NAME),
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function mergeWithoutOverwrite(source: string, destination: string, report: StorageMigrationReport): void {
|
|
35
|
+
if (!fs.existsSync(source)) return;
|
|
36
|
+
if (!fs.existsSync(destination)) {
|
|
37
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
38
|
+
fs.renameSync(source, destination);
|
|
39
|
+
report.movedEntries++;
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const sourceStat = fs.statSync(source);
|
|
43
|
+
const destinationStat = fs.statSync(destination);
|
|
44
|
+
if (!sourceStat.isDirectory() || !destinationStat.isDirectory()) {
|
|
45
|
+
report.warnings.push(`storage migration left a conflicting path untouched: ${source}`);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
for (const entry of fs.readdirSync(source)) {
|
|
49
|
+
mergeWithoutOverwrite(path.join(source, entry), path.join(destination, entry), report);
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
if (fs.readdirSync(source).length === 0) fs.rmdirSync(source);
|
|
53
|
+
} catch {
|
|
54
|
+
// A partial migration remains readable through the legacy path fallback.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
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
|
+
*/
|
|
62
|
+
export function migrateLegacyAgentStorage(): StorageMigrationReport {
|
|
63
|
+
const report: StorageMigrationReport = { movedEntries: 0, warnings: [] };
|
|
64
|
+
const source = getLegacyAgentStorageDirectory();
|
|
65
|
+
const destination = getAgentStorageDirectory();
|
|
66
|
+
try {
|
|
67
|
+
mergeWithoutOverwrite(source, destination, report);
|
|
68
|
+
const legacySettings = path.join(destination, LEGACY_SETTINGS_FILE_NAME);
|
|
69
|
+
const settings = getAgentSettingsPath();
|
|
70
|
+
if (fs.existsSync(legacySettings)) mergeWithoutOverwrite(legacySettings, settings, report);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
report.warnings.push(error instanceof Error ? error.message : String(error));
|
|
73
|
+
}
|
|
74
|
+
return report;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Translate paths persisted before the storage directory rename. */
|
|
78
|
+
export function resolveMigratedStoragePath(file: string): string {
|
|
79
|
+
const source = path.resolve(file);
|
|
80
|
+
const legacyRoot = path.resolve(getLegacyAgentStorageDirectory());
|
|
81
|
+
if (source !== legacyRoot && !source.startsWith(`${legacyRoot}${path.sep}`)) return source;
|
|
82
|
+
// A collision-safe merge leaves the legacy source in place. Prefer the exact
|
|
83
|
+
// persisted path instead of shadowing it with a different destination file.
|
|
84
|
+
if (fs.existsSync(source)) return source;
|
|
85
|
+
const translated = path.join(getAgentStorageDirectory(), path.relative(legacyRoot, source));
|
|
86
|
+
return fs.existsSync(translated) ? translated : source;
|
|
87
|
+
}
|
package/types.ts
CHANGED
|
@@ -104,6 +104,23 @@ export interface AgentCounts {
|
|
|
104
104
|
residentSlots: number;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
export interface AgentUsageTotals {
|
|
108
|
+
input: number;
|
|
109
|
+
output: number;
|
|
110
|
+
cacheRead: number;
|
|
111
|
+
cacheWrite: number;
|
|
112
|
+
total: number;
|
|
113
|
+
cost: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface AgentUsageReport {
|
|
117
|
+
main: AgentUsageTotals;
|
|
118
|
+
subagents: AgentUsageTotals;
|
|
119
|
+
combined: AgentUsageTotals;
|
|
120
|
+
subagentCount: number;
|
|
121
|
+
unreadableSubagents: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
107
124
|
export interface AgentTranscriptView {
|
|
108
125
|
agent: AgentView;
|
|
109
126
|
sessionFile: string;
|
|
@@ -137,6 +154,7 @@ export interface ForkContextPayload {
|
|
|
137
154
|
export interface ChildMetaPayload {
|
|
138
155
|
path: string;
|
|
139
156
|
parentPath: string;
|
|
157
|
+
rootSessionId?: string;
|
|
140
158
|
role?: string;
|
|
141
159
|
}
|
|
142
160
|
|
package/viewer.ts
CHANGED
|
@@ -17,7 +17,13 @@ import {
|
|
|
17
17
|
visibleWidth,
|
|
18
18
|
type TUI,
|
|
19
19
|
} from "@earendil-works/pi-tui";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
ROOT_PATH,
|
|
22
|
+
type AgentTranscriptView,
|
|
23
|
+
type AgentUsageReport,
|
|
24
|
+
type AgentUsageTotals,
|
|
25
|
+
type AgentView,
|
|
26
|
+
} from "./types.ts";
|
|
21
27
|
|
|
22
28
|
type ChangeSubscriber = (listener: () => void) => () => void;
|
|
23
29
|
|
|
@@ -73,6 +79,77 @@ function itemLine(agent: AgentView, theme: Theme, width: number): string {
|
|
|
73
79
|
return `${clipped}${" ".repeat(Math.max(1, width - visibleWidth(clipped) - visibleWidth(right)))}${right}`;
|
|
74
80
|
}
|
|
75
81
|
|
|
82
|
+
function usagePromptTokens(usage: AgentUsageTotals): number {
|
|
83
|
+
return usage.input + usage.cacheRead + usage.cacheWrite;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function usageCacheRate(usage: AgentUsageTotals): string {
|
|
87
|
+
const prompt = usagePromptTokens(usage);
|
|
88
|
+
return prompt > 0 ? `${((usage.cacheRead / prompt) * 100).toFixed(1)}%` : "n/a";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function formatAgentUsage(report: AgentUsageReport): string {
|
|
92
|
+
const lines = [
|
|
93
|
+
"Agent Usage",
|
|
94
|
+
"",
|
|
95
|
+
`Main agent: ${report.main.total.toLocaleString()} tokens · cache ${usageCacheRate(report.main)}`,
|
|
96
|
+
`Sub-agents: ${report.subagents.total.toLocaleString()} tokens · cache ${usageCacheRate(report.subagents)}`,
|
|
97
|
+
`Combined: ${report.combined.total.toLocaleString()} tokens`,
|
|
98
|
+
"",
|
|
99
|
+
`Sub-agents counted: ${report.subagentCount - report.unreadableSubagents}/${report.subagentCount}`,
|
|
100
|
+
];
|
|
101
|
+
if (report.combined.cost > 0) lines.push(`Combined cost: $${report.combined.cost.toFixed(3)}`);
|
|
102
|
+
if (report.unreadableSubagents > 0) lines.push(`Unreadable sessions: ${report.unreadableSubagents}`);
|
|
103
|
+
return lines.join("\n");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class AgentUsageViewer {
|
|
107
|
+
constructor(
|
|
108
|
+
private readonly theme: Theme,
|
|
109
|
+
private readonly keybindings: KeybindingsManager,
|
|
110
|
+
private readonly report: AgentUsageReport,
|
|
111
|
+
private readonly done: () => void,
|
|
112
|
+
) {}
|
|
113
|
+
|
|
114
|
+
handleInput(data: string): void {
|
|
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 {}
|
|
151
|
+
}
|
|
152
|
+
|
|
76
153
|
export class AgentPickerComponent {
|
|
77
154
|
private agents: AgentView[] = [];
|
|
78
155
|
private selectedIndex = 0;
|