@youngjurry/pi-agents 0.7.4 → 0.8.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 CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 - 2026-09-09
4
+
5
+ - 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.
6
+ - 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.
7
+ - Archive legacy flat session/result files only after scanning every ordinary Pi main session and confirming that no persisted agent state references their IDs.
8
+ - 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.
9
+ - Repair missing queued-session files from affected older releases with their persisted IDs; already-lost historical fork context cannot be reconstructed.
10
+ - Record the owning root Session ID directly in newly created child metadata.
11
+
12
+ ## 0.7.5 - 2026-09-05
13
+
14
+ - Exclude the active `wait_agent` caller from its folded child-status summary.
15
+ - Prevent the root Agent's necessarily `running` tool turn from appearing as a phantom running sub-agent.
16
+ - Scope nested waits to the caller's own subtree while preserving `Ctrl+O` expansion.
17
+
3
18
  ## 0.7.4 - 2026-09-05
4
19
 
5
20
  - Match Pi's progressive Skill disclosure for sub-agent Roles instead of eagerly injecting complete `SKILL.md` files.
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,17 +180,20 @@ 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
192
+ - Legacy flat files are archived only after every ordinary Pi main session has been scanned and no persisted agent-state reference exists
180
193
  - Referenced legacy flat child files are migrated when their main session is resumed
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
- - `wait_agent` sends only newly queued mailbox notices to the model; its full status tree is folded in the TUI by default and can be toggled with `Ctrl+O`
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`
184
197
  - Failed notice delivery is re-queued instead of silently discarded
185
198
  - Notices pending when a turn is aborted or errors are deferred to the next explicit turn without restarting the interrupted agent
186
199
  - The extension never inserts messages between an assistant tool call and its tool result, keeping session history protocol-valid for strict gateways
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 { archiveUnownedLegacyFiles, 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,8 @@ 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;
104
+ let legacyArchiveStarted = false;
101
105
 
102
106
  const updateUi = () => {
103
107
  const ctx = activeContext;
@@ -124,6 +128,25 @@ export default function codexAgentsExtension(pi: ExtensionAPI): void {
124
128
  pi.on("session_start", (event, ctx) => {
125
129
  activeContext = ctx;
126
130
  control.bindRoot(ctx);
131
+ if (!storageMigrationReported) {
132
+ storageMigrationReported = true;
133
+ if (storageMigration.movedEntries > 0) {
134
+ ctx.ui.notify(`Migrated agent storage to ~/.pi/agent/pi-agents (${storageMigration.movedEntries} entries).`, "info");
135
+ }
136
+ for (const warning of storageMigration.warnings) ctx.ui.notify(`Agent storage migration: ${warning}`, "warning");
137
+ }
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
+ }
127
150
  const resumedExistingSession = event.reason === "resume"
128
151
  || (event.reason === "startup" && ctx.sessionManager.getEntries().some((entry) => entry.type === "message"));
129
152
  if (resumedExistingSession) {
@@ -199,6 +222,36 @@ export default function codexAgentsExtension(pi: ExtensionAPI): void {
199
222
  return new Text(`${header}\n${theme.fg("customMessageText", body)}`, 1, 0);
200
223
  });
201
224
 
225
+ pi.registerCommand("agent-usage", {
226
+ description: "Show main, sub-agent, and combined token usage",
227
+ handler: async (_args, ctx) => {
228
+ activeContext = ctx;
229
+ let report;
230
+ try {
231
+ report = control.getUsage(ctx);
232
+ } catch (error) {
233
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
234
+ return;
235
+ }
236
+ if (ctx.mode !== "tui") {
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
+ }
252
+ },
253
+ });
254
+
202
255
  pi.registerCommand("agents", {
203
256
  description: "Browse sub-agent status and inspect read-only session transcripts",
204
257
  getArgumentCompletions: (prefix) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youngjurry/pi-agents",
3
- "version": "0.7.4",
3
+ "version": "0.8.0",
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,247 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { createInterface } from "node:readline";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ import { STATE_ENTRY_TYPE } from "./types.ts";
6
+
7
+ const STORAGE_DIRECTORY_NAME = "pi-agents";
8
+ const LEGACY_STORAGE_DIRECTORY_NAME = "codex-agents";
9
+ const SETTINGS_FILE_NAME = "settings.json";
10
+ const LEGACY_SETTINGS_FILE_NAME = "agents-setting.json";
11
+
12
+ export interface StorageMigrationReport {
13
+ movedEntries: number;
14
+ warnings: string[];
15
+ }
16
+
17
+ export interface LegacyArchiveReport {
18
+ archivedFiles: number;
19
+ retainedFiles: number;
20
+ scannedMainSessions: number;
21
+ archiveDirectory?: string;
22
+ error?: string;
23
+ }
24
+
25
+ export function getAgentStorageDirectory(): string {
26
+ return path.join(getAgentDir(), STORAGE_DIRECTORY_NAME);
27
+ }
28
+
29
+ export function getLegacyAgentStorageDirectory(): string {
30
+ return path.join(getAgentDir(), LEGACY_STORAGE_DIRECTORY_NAME);
31
+ }
32
+
33
+ export function getAgentSettingsPath(): string {
34
+ return path.join(getAgentStorageDirectory(), SETTINGS_FILE_NAME);
35
+ }
36
+
37
+ export function getLegacyAgentSettingsPaths(): string[] {
38
+ return [
39
+ path.join(getAgentStorageDirectory(), LEGACY_SETTINGS_FILE_NAME),
40
+ path.join(getLegacyAgentStorageDirectory(), LEGACY_SETTINGS_FILE_NAME),
41
+ ];
42
+ }
43
+
44
+ function mergeWithoutOverwrite(source: string, destination: string, report: StorageMigrationReport): void {
45
+ if (!fs.existsSync(source)) return;
46
+ if (!fs.existsSync(destination)) {
47
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
48
+ fs.renameSync(source, destination);
49
+ report.movedEntries++;
50
+ return;
51
+ }
52
+ const sourceStat = fs.statSync(source);
53
+ const destinationStat = fs.statSync(destination);
54
+ if (!sourceStat.isDirectory() || !destinationStat.isDirectory()) {
55
+ report.warnings.push(`storage migration left a conflicting path untouched: ${source}`);
56
+ return;
57
+ }
58
+ for (const entry of fs.readdirSync(source)) {
59
+ mergeWithoutOverwrite(path.join(source, entry), path.join(destination, entry), report);
60
+ }
61
+ try {
62
+ if (fs.readdirSync(source).length === 0) fs.rmdirSync(source);
63
+ } catch {
64
+ // A partial migration remains readable through the legacy path fallback.
65
+ }
66
+ }
67
+
68
+ /** Move the old package-owned directory without overwriting newer data. */
69
+ export function migrateLegacyAgentStorage(): StorageMigrationReport {
70
+ const report: StorageMigrationReport = { movedEntries: 0, warnings: [] };
71
+ const source = getLegacyAgentStorageDirectory();
72
+ const destination = getAgentStorageDirectory();
73
+ try {
74
+ mergeWithoutOverwrite(source, destination, report);
75
+ const legacySettings = path.join(destination, LEGACY_SETTINGS_FILE_NAME);
76
+ const settings = getAgentSettingsPath();
77
+ if (fs.existsSync(legacySettings)) mergeWithoutOverwrite(legacySettings, settings, report);
78
+ } catch (error) {
79
+ report.warnings.push(error instanceof Error ? error.message : String(error));
80
+ }
81
+ return report;
82
+ }
83
+
84
+ /** Translate paths persisted before the storage directory rename. */
85
+ export function resolveMigratedStoragePath(file: string): string {
86
+ const source = path.resolve(file);
87
+ const legacyRoot = path.resolve(getLegacyAgentStorageDirectory());
88
+ if (source !== legacyRoot && !source.startsWith(`${legacyRoot}${path.sep}`)) return source;
89
+ // A collision-safe merge leaves the legacy source in place. Prefer the exact
90
+ // persisted path whenever it still exists rather than shadowing it with a
91
+ // different destination file that happens to share its relative name.
92
+ if (fs.existsSync(source)) return source;
93
+ const translated = path.join(getAgentStorageDirectory(), path.relative(legacyRoot, source));
94
+ return fs.existsSync(translated) ? translated : source;
95
+ }
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/tools.ts CHANGED
@@ -200,7 +200,7 @@ export function createCollaborationTools(control: AgentControl): ToolDefinition[
200
200
  const outcome = await control.waitForMailbox(ctx, params.timeout_ms, signal);
201
201
  if (outcome.aborted) throw new Error("wait_agent was aborted");
202
202
  const notices = control.drainPendingMail(sender);
203
- const agents = control.list(ctx);
203
+ const agents = control.list(ctx, sender).filter((agent) => agent.path !== sender);
204
204
  const text = notices.length > 0
205
205
  ? `Mailbox activity received:\n\n${notices.join("\n\n")}`
206
206
  : outcome.timedOut
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;