@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 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/codex-agents/agents-setting.json`
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/codex-agents/roots/<root-session-id>/sessions/` and reload lazily
177
- - Full final answers persist under `~/.pi/agent/codex-agents/roots/<root-session-id>/results/`
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
- - Resuming an existing main session removes groups whose owning main-session file has been deleted; new sessions and `/reload` do not trigger cleanup
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(getAgentDir(), "codex-agents", "roots");
174
- private childSessionDirectory = path.join(getAgentDir(), "codex-agents", "sessions");
175
- private agentResultDirectory = path.join(getAgentDir(), "codex-agents", "results");
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 source = path.resolve(file);
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: false };
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
- if (owner.rootSessionFile && fs.existsSync(owner.rootSessionFile)) continue;
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-setting.json");
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 sessionManager = SessionManager.open(record.sessionFile);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youngjurry/pi-agents",
3
- "version": "0.7.5",
3
+ "version": "0.8.1",
4
4
  "description": "Persistent in-process Codex-style multi-agent collaboration for Pi",
5
5
  "author": "youngjurry",
6
6
  "type": "module",
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 { getAgentDir } from "@earendil-works/pi-coding-agent";
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 path.join(getAgentDir(), "codex-agents", "agents-setting.json");
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
- if (!fs.existsSync(filePath)) return {};
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(filePath, "utf8"));
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 ${filePath}: ${message}`);
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 ${filePath} must contain a JSON object`);
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 ${filePath} must be a non-empty provider/model string`);
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 ${filePath} must be one of: ${CHILD_THINKING_LEVELS.join(", ")}`);
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 ${filePath} must be a positive integer`);
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 { ROOT_PATH, type AgentTranscriptView, type AgentView } from "./types.ts";
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;