@tallshort/pi-inclusive-cost 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Serhii Chernenko
4
+ Copyright (c) 2026 tallshort
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,148 @@
1
- # Temporary Holding Version
1
+ # @tallshort/pi-inclusive-cost
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/@tallshort/pi-inclusive-cost?logo=npm)](https://www.npmjs.com/package/@tallshort/pi-inclusive-cost)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@tallshort/pi-inclusive-cost?logo=npm)](https://www.npmjs.com/package/@tallshort/pi-inclusive-cost)
5
+ [![CI](https://github.com/tallshort/pi-inclusive-cost/actions/workflows/ci.yml/badge.svg)](https://github.com/tallshort/pi-inclusive-cost/actions/workflows/ci.yml)
6
+ [![GitHub stars](https://img.shields.io/github/stars/tallshort/pi-inclusive-cost?style=flat)](https://github.com/tallshort/pi-inclusive-cost/stargazers)
7
+
8
+ A [Pi coding agent](https://pi.dev) extension that shows **inclusive cost** for the current session, including supported subagents and delegated workflows, not just the main thread.
9
+
10
+ > [!NOTE]
11
+ > This repository is the `tallshort`-maintained fork of [serhii-chernenko/pi-inclusive-cost](https://github.com/serhii-chernenko/pi-inclusive-cost), which remains the upstream project.
12
+
13
+ ## What this fork adds
14
+
15
+ Compared with upstream, this fork adds:
16
+
17
+ - Attributed cost collectors for Pi Fabric, Pi Taskflow, and a broad set of named subagent extensions.
18
+ - Strict current-chat attribution with stable contribution identities, cumulative-cost deduplication, and no blanket scans of unrelated shared histories.
19
+ - Lifecycle-aware accounting for persisted foreground, background, and nested work, plus backend-specific resume, cache, and recompute semantics.
20
+ - A modular collector architecture with backend-specific source precedence, conservative fallbacks, and regression coverage.
21
+
22
+ Example statusline with illustrative values:
23
+
24
+ ```text
25
+ ↑1.9M ↓176k R62M CH8.9% $34.609 (sub) 66.8%/272k (auto) (openai-codex) gpt-5.6-sol • medium
26
+ $37.72 (incl)
27
+ ```
28
+
29
+ ## The problem
30
+
31
+ Pi's built-in statusline sums cost from the current session's own message history only. If you use a subagent extension (e.g. one that spawns background workers, reviewers, researchers, etc.), those subagent sessions are written to their own nested session files and are invisible to the built-in cost total — so the number in your statusline can understate what a task actually cost by a wide margin.
32
+
33
+ ## What this does
34
+
35
+ The extension rebuilds a deduplicated total from persisted records attributable to the current chat.
36
+
37
+ ### Collectors
38
+
39
+ | Collector | Attributed source |
40
+ | --- | --- |
41
+ | Standard Pi sessions | Root and linked child Session JSONL files containing `usage.cost.total` |
42
+ | `@pi-archimedes/subagent` | Attributed synchronous `details.results[].usage.cost` records |
43
+ | `pi-subagents-lite` | Foreground `Agent` tool-result cost and attributed background completion messages |
44
+ | `@ian-pascoe/pi-minimal-subagents` | Root Session registry `turn-settled` usage, keyed by stable agent and turn identity |
45
+ | `pi-better-subagents` | Attributed launch run IDs and their retained sandboxed per-run standard child Session usage |
46
+ | Explicit Herdr child Sessions | Attributed `pi-herdr-agents` launch paths and `@henryqw/pi-subagent` direct-session results |
47
+ | `@bacnh85/pi-subagent` | Attributed foreground tool-result usage and background completion messages |
48
+ | `pi-subagents-j0k3r` | Attributed `subagent_*` tool-result task usage, with an explicitly referenced standard session as fallback |
49
+ | `@ferris1225/pi-subagents` | Run IDs from attributed launch results and cumulative usage from persisted `subagent_status` results; explicit standard sessions are fallback only |
50
+ | Pi Dynamic Workflows | Attributed `workflow` run IDs and cumulative cost from the run's small persisted v2 head |
51
+ | Pi Fabric | Run IDs and cumulative usage from `fabric_exec` rendered output or structured agent audit details, with attributable `.fabric` exports as fallback |
52
+ | Pi Taskflow | Run IDs returned by `taskflow`, then usage-bearing trace events or an older snapshot fallback |
53
+
54
+ ### Supported extensions
55
+
56
+ | Extension | Collector | Notes |
57
+ | --- | --- | --- |
58
+ | Standard full-session subagent modes | Standard Pi sessions | Layout-based compatibility for nested `session.jsonl` or parent-linked sibling sessions; not a blanket claim about any package named `pi-subagents` |
59
+ | `@pi-archimedes/subagent` | `@pi-archimedes/subagent` | Synchronous results expose stable child Session IDs and usage without persisting child Session files |
60
+ | `pi-subagents-lite` | `pi-subagents-lite` | Foreground calls persist cumulative cost; background completions are linked to attributed launch IDs |
61
+ | `@ian-pascoe/pi-minimal-subagents` | `@ian-pascoe/pi-minimal-subagents` | Registry V2 settled-turn usage is authoritative; legacy V1 records and child Session totals are not collected |
62
+ | `pi-better-subagents` | `pi-better-subagents` | Strictly attributed retained sandboxed child Sessions only; shared session trees and large `output.log` files are not scanned |
63
+ | `@henryqw/pi-subagent` | Explicit Herdr child Sessions | Direct mode only: attributed launch and completion records identify standard child Sessions; isolated mode has no collectible persisted usage |
64
+ | `pi-herdr-agents` | Explicit Herdr child Sessions | Attributed launch records identify standard child Sessions; fork-inherited parent entries are excluded |
65
+ | `@gotgenes/pi-subagents` | Standard Pi sessions | Independently maintained hard fork of `@tintinweb/pi-subagents`; supported through its `tasks/*.jsonl` layout |
66
+ | `@nklisch/pi-subagents` | Standard Pi sessions | Maintained MIT fork of `@gotgenes/pi-subagents`; retains the same `tasks/*.jsonl` persistence while adding ordered lifecycle interception |
67
+ | `@xzzpig/pi-subagents` | Standard Pi sessions | Fork of `nicobailon/pi-subagents`; persisted native children use the same nested-session mechanism |
68
+ | `oira666_pi-subagent` | Standard Pi sessions | Persisted RPC children use nested `subagents/.../session.jsonl` files under the parent session stem |
69
+ | `@bacnh85/pi-subagent` | `@bacnh85/pi-subagent` | In-memory foreground results are keyed by parent tool call; background completions are keyed by task ID |
70
+ | `pi-subagents-j0k3r` | `pi-subagents-j0k3r` | Parent tool results provide stable task identities and usage; explicit child sessions are fallback only |
71
+ | `@ferris1225/pi-subagents` | `@ferris1225/pi-subagents` | Launch results establish stable run IDs; persisted status results provide usage; detached completion text is not parsed |
72
+ | Pi Dynamic Workflows | Pi Dynamic Workflows | Exact parent Session identity and run ID locate a small cumulative run head; large event logs are not read |
73
+ | Pi Fabric | Pi Fabric | Requires the parent chat to retain the Fabric run ID in rendered output or structured audit details |
74
+ | Pi Taskflow | Pi Taskflow | Requires the parent chat to retain the Taskflow run ID |
75
+
76
+ Support for a named subagent extension means its attributable persisted mode is covered; in-memory or explicitly non-persistent modes remain out of scope. In particular, `@henryqw/pi-subagent` isolated mode uses `--no-session` and does not persist its internally observed usage in the parent result, so only direct mode is collected. The generic first row is based only on the standard persisted layout.
77
+
78
+ The statusline shows `$X.XX (incl)` whenever attributable delegated work exists. `/cost` keeps the compact three-line summary: `Direct (this thread)`, `Inclusive (+ subagents & delegated work)`, and `Subagent/delegated contributions folded in`. Refresh happens on session start, at the end of every turn, and when the agent run ends.
79
+
80
+ See [`docs/DESIGN.md`](docs/DESIGN.md) for persistence layouts, source precedence, resume/cache/recompute behavior, contribution keys, and deduplication invariants.
81
+
82
+ It intentionally does not perform its own token-based cost estimation—it sums monetary cost fields already persisted by supported runtimes, such as Session `usage.cost.total` or Dynamic Workflows `tokenUsage.cost`. These values are themselves **client-side estimates** computed from reported token counts and local model pricing, not authoritative provider bills. When a runtime marks token usage as estimated but has no monetary cost, that unknown cost is omitted rather than treated as free.
83
+
84
+ ## Install
85
+
86
+ ```bash
87
+ pi install npm:@tallshort/pi-inclusive-cost
88
+ ```
89
+
90
+ or directly from source:
91
+
92
+ ```bash
93
+ pi install git:github.com/tallshort/pi-inclusive-cost
94
+ ```
95
+
96
+ Restart Pi, or run `/reload`.
97
+
98
+ ## Commands
99
+
100
+ - `/cost` — direct cost, inclusive cost from subagents and other delegated work, and the number of subagent/delegated contributions folded into the total.
101
+
102
+ ## Not currently collected
103
+
104
+ | Extension | Reason |
105
+ | --- | --- |
106
+ | `@esso0428/pi-subagents` | Defaults to in-memory children and custom `.output` transcripts without attributable priced usage |
107
+ | `@narumitw/pi-subagents` | Starts children with `--no-session` and exposes neither stable `jobId + usage.cost` data nor attributable child Sessions; upstream support is required first |
108
+ | `wj-pi-subagents` | Starts children with `--no-session`; child replies contain text but no usage/cost |
109
+
110
+ `@tintinweb/pi-subagents` is intentionally not given a package-specific collector: its optional `reportUsage` mode reports child cost through the parent Pi tool result, so collecting the same child usage again could double-count it.
111
+
112
+ The unscoped `pi-subagents` name in the preserved upstream limitation below did not include a package identity or version range. Treat it as a description of the observed artifacts-only layout, not as a claim about every similarly named npm package or fork.
113
+
114
+ ## Known limitation: newer artifacts-only subagent storage isn't covered yet
115
+
116
+ `pi-subagents` has changed how it persists at least one subagent mode (parallel/fork batches) between versions. Older versions always wrote a full session file for every subagent invocation, nested or as a flat sibling — the two mechanisms above. A newer mode instead writes only to `<project-cwd>/.pi-subagents/artifacts/<runId>_<agent>_<index>_meta.json` (plus a `_transcript.jsonl` with a different record schema), with **no corresponding session file anywhere** — nested or sibling. That cost is currently invisible to this extension.
117
+
118
+ This wasn't skipped by accident: there's no reliable way to attribute a given artifacts file back to one specific chat. The only correlation lead found (a `session_info` entry naming the subagent, written into the parent's own session file) turns out to depend on the `pi-intercom` package being installed — not a safe thing to build a core feature on. A worktree-wide sum without that correlation would also reintroduce exactly the cross-chat over-counting problem this extension was built to avoid in the first place (multiple unrelated chats in the same project folder getting lumped together). Until there's a first-party, version-stable way to scope this correctly, it's left uncovered rather than shipped fragile or wrong.
119
+
120
+ Collector-specific limitations for Fabric and Taskflow are documented in [`docs/DESIGN.md`](docs/DESIGN.md).
121
+
122
+ ## Don't expect this to match your provider's billing dashboard exactly
123
+
124
+ `usage.cost.total` is computed locally from token counts and a local price table — it is not your provider's authoritative bill. Even with accurate, current pricing, a locally-summed total can diverge from what your provider's own usage dashboard reports, for reasons outside this extension's control: reporting lag on the provider's side, per-request billing multipliers instead of per-token metering, or negotiated/enterprise rates that differ from the public price list. If you need a number that reconciles with billing, treat this as a fast local signal, not a substitute for your provider's own dashboard.
125
+
126
+ For a full project-wide audit across every chat (not just the current one), use a dedicated usage-dashboard tool — this extension is meant for a quick, live, per-chat signal in the statusline.
127
+
128
+ ## Releases
129
+
130
+ The first scoped release must create the public npm package before Trusted Publishing can be attached. From the clean release commit:
131
+
132
+ ```bash
133
+ npm login
134
+ npm publish --access public
135
+ npm trust github '@tallshort/pi-inclusive-cost' \
136
+ --repo tallshort/pi-inclusive-cost \
137
+ --file publish.yml \
138
+ --allow-publish \
139
+ --yes
140
+ git tag v0.2.0
141
+ git push origin main v0.2.0
142
+ ```
143
+
144
+ For later releases, bump `version` in `package.json`, commit, then push the matching tag. The tag workflow type-checks, tests, publishes through npm Trusted Publishing, and creates a GitHub Release with generated notes. It skips npm publication when that exact package version already exists, so the first-release tag only creates the matching GitHub Release. No npm token is stored in the repository; `npm trust` requires npm CLI ≥11.15.0.
145
+
146
+ ## License
147
+
148
+ MIT
@@ -0,0 +1,115 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+
3
+ import {
4
+ asRecord,
5
+ finiteNonNegative,
6
+ setGreatestContribution,
7
+ sumContributions,
8
+ type AttributedToolResult,
9
+ type CollectorSummary,
10
+ } from "./shared.js";
11
+
12
+ interface ArchimedesResult {
13
+ identity: string;
14
+ record: Record<string, unknown>;
15
+ }
16
+
17
+ export function collectArchimedes(toolResults: AttributedToolResult[]): CollectorSummary {
18
+ const contributions = new Map<string, number>();
19
+ const parentReported = new Set<string>();
20
+
21
+ for (const result of toolResults) {
22
+ if (reportedToolCost(result.usage) === undefined) continue;
23
+ for (const child of archimedesResults(result)) parentReported.add(child.identity);
24
+ }
25
+
26
+ for (const result of toolResults) {
27
+ for (const child of archimedesResults(result)) {
28
+ if (parentReported.has(child.identity)) continue;
29
+ const cost = finiteNonNegative(asRecord(child.record.usage)?.cost);
30
+ if (cost !== undefined) setGreatestContribution(contributions, child.identity, cost);
31
+ }
32
+ }
33
+
34
+ return {
35
+ cost: sumContributions(contributions),
36
+ count: contributions.size,
37
+ };
38
+ }
39
+
40
+ function archimedesResults(result: AttributedToolResult): ArchimedesResult[] {
41
+ if (result.toolName !== "subagent") return [];
42
+ const details = asRecord(result.details);
43
+ if (!details || !Array.isArray(details.results) || !Array.isArray(details.progress)) return [];
44
+ if (details.mode !== "single" && details.mode !== "parallel") return [];
45
+ if (details.progress.length !== details.results.length) return [];
46
+
47
+ const children: ArchimedesResult[] = [];
48
+ for (let index = 0; index < details.results.length; index++) {
49
+ const record = asRecord(details.results[index]);
50
+ const progress = asRecord(details.progress[index]);
51
+ if (!isArchimedesResult(record, progress)) continue;
52
+ const identity = archimedesIdentity(result, record, index);
53
+ if (identity) children.push({ identity, record });
54
+ }
55
+ return children;
56
+ }
57
+
58
+ function isArchimedesResult(
59
+ result: Record<string, unknown> | null,
60
+ detailsProgress: Record<string, unknown> | null,
61
+ ): result is Record<string, unknown> {
62
+ if (!result || !detailsProgress || typeof result.agent !== "string" || typeof result.task !== "string") return false;
63
+ if (typeof result.exitCode !== "number") return false;
64
+ if (result.childSessionId !== undefined && typeof result.childSessionId !== "string") return false;
65
+ if (result.model !== undefined && typeof result.model !== "string") return false;
66
+ if (result.finalOutput !== undefined && typeof result.finalOutput !== "string") return false;
67
+ if (result.error !== undefined && typeof result.error !== "string") return false;
68
+
69
+ const progress = asRecord(result.progress);
70
+ if (!progress || (progress.status !== "completed" && progress.status !== "failed")) return false;
71
+ if (progress.agent !== result.agent || progress.task !== result.task) return false;
72
+ for (const field of ["toolCount", "inputTokens", "outputTokens", "tokens", "cost", "durationMs"] as const) {
73
+ if (finiteNonNegative(progress[field]) === undefined) return false;
74
+ }
75
+ if (!isDeepStrictEqual(detailsProgress, progress)) return false;
76
+
77
+ const summary = asRecord(result.progressSummary);
78
+ if (!summary || finiteNonNegative(summary.toolCount) === undefined
79
+ || finiteNonNegative(summary.tokens) === undefined
80
+ || finiteNonNegative(summary.durationMs) === undefined) return false;
81
+ if (summary.toolCount !== progress.toolCount || summary.tokens !== progress.tokens
82
+ || summary.durationMs !== progress.durationMs) return false;
83
+
84
+ const usage = asRecord(result.usage);
85
+ if (result.usage !== undefined && !isArchimedesUsage(usage)) return false;
86
+ return true;
87
+ }
88
+
89
+ function archimedesIdentity(
90
+ result: AttributedToolResult,
91
+ child: Record<string, unknown>,
92
+ index: number,
93
+ ): string | undefined {
94
+ if (typeof child.childSessionId === "string" && isPiSessionId(child.childSessionId)) {
95
+ return `archimedes:${child.childSessionId.toLowerCase()}`;
96
+ }
97
+ if (!result.toolCallId) return undefined;
98
+ return `archimedes-fallback:${result.sessionFile}:${result.toolCallId}:${index}`;
99
+ }
100
+
101
+ function isArchimedesUsage(usage: Record<string, unknown> | null): boolean {
102
+ if (!usage) return false;
103
+ for (const field of ["input", "output", "cacheRead", "cacheWrite", "cost", "turns"] as const) {
104
+ if (finiteNonNegative(usage[field]) === undefined) return false;
105
+ }
106
+ return true;
107
+ }
108
+
109
+ function isPiSessionId(value: string): boolean {
110
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
111
+ }
112
+
113
+ function reportedToolCost(value: unknown): number | undefined {
114
+ return finiteNonNegative(asRecord(asRecord(value)?.cost)?.total);
115
+ }
@@ -0,0 +1,77 @@
1
+ import {
2
+ asRecord,
3
+ finiteNonNegative,
4
+ setGreatestContribution,
5
+ sumContributions,
6
+ type AttributedCustomMessage,
7
+ type AttributedToolResult,
8
+ type CollectorSummary,
9
+ } from "./shared.js";
10
+
11
+ const BACNH85_TOOL_NAME = "subagent";
12
+ const BACNH85_COMPLETION_TYPE = "pi-subagent-complete";
13
+
14
+ export function collectBacnh85(
15
+ toolResults: AttributedToolResult[],
16
+ customMessages: AttributedCustomMessage[],
17
+ ): CollectorSummary {
18
+ const contributions = new Map<string, number>();
19
+
20
+ for (const result of toolResults) {
21
+ if (result.toolName !== BACNH85_TOOL_NAME || !result.toolCallId) continue;
22
+ if (reportedToolCost(result.usage) !== undefined) continue;
23
+ const details = asRecord(result.details);
24
+ if (!isBacnh85Details(details) || details.runner === "herdr") continue;
25
+ const results = details.results as unknown[];
26
+ for (let index = 0; index < results.length; index++) {
27
+ const child = asRecord(results[index]);
28
+ if (!isBacnh85Result(child)) continue;
29
+ const cost = finiteNonNegative(asRecord(child.usage)?.cost);
30
+ if (cost === undefined) continue;
31
+ setGreatestContribution(
32
+ contributions,
33
+ `bacnh85:${result.sessionFile}:${result.toolCallId}:${index}`,
34
+ cost,
35
+ );
36
+ }
37
+ }
38
+
39
+ for (const message of customMessages) {
40
+ if (message.customType !== BACNH85_COMPLETION_TYPE) continue;
41
+ const details = asRecord(message.details);
42
+ if (details?.background !== true || typeof details.task_id !== "string" || !details.task_id.startsWith("bg-")) continue;
43
+ const cost = finiteNonNegative(asRecord(details.usage)?.cost);
44
+ if (cost === undefined) continue;
45
+ setGreatestContribution(
46
+ contributions,
47
+ `bacnh85-background:${message.sessionFile}:${details.task_id}`,
48
+ cost,
49
+ );
50
+ }
51
+
52
+ return {
53
+ cost: sumContributions(contributions),
54
+ count: contributions.size,
55
+ };
56
+ }
57
+
58
+ function isBacnh85Details(details: Record<string, unknown> | null): details is Record<string, unknown> & { results: unknown[] } {
59
+ if (!details || !Array.isArray(details.results)) return false;
60
+ if (details.mode !== "single" && details.mode !== "parallel" && details.mode !== "chain") return false;
61
+ if (details.agentScope !== "user" && details.agentScope !== "project" && details.agentScope !== "both") return false;
62
+ return details.projectAgentsDir === null || typeof details.projectAgentsDir === "string";
63
+ }
64
+
65
+ function isBacnh85Result(result: Record<string, unknown> | null): result is Record<string, unknown> {
66
+ return result !== null
67
+ && typeof result.agent === "string"
68
+ && typeof result.task === "string"
69
+ && typeof result.exitCode === "number"
70
+ && Array.isArray(result.messages)
71
+ && typeof result.stderr === "string";
72
+ }
73
+
74
+ function reportedToolCost(value: unknown): number | undefined {
75
+ const usage = asRecord(value);
76
+ return finiteNonNegative(asRecord(usage?.cost)?.total);
77
+ }
@@ -0,0 +1,316 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { StringDecoder } from "node:string_decoder";
5
+
6
+ import {
7
+ asRecord,
8
+ contentText,
9
+ finiteNonNegative,
10
+ readJsonFile,
11
+ sumContributions,
12
+ type AttributedToolResult,
13
+ type CollectorSummary,
14
+ } from "./shared.js";
15
+
16
+ const RUN_ID = /^sa_[a-z0-9]+_[a-z0-9]+$/i;
17
+ const MAX_SESSION_LINE_BYTES = 16 * 1024 * 1024;
18
+ const READ_CHUNK_BYTES = 64 * 1024;
19
+ const MAX_SESSION_CANDIDATES = 32;
20
+ const RUN_STATUSES = new Set(["running", "completed", "failed", "killed", "orphaned", "lost"]);
21
+
22
+ interface ParentIdentity {
23
+ sessionId: string;
24
+ cwd: string;
25
+ }
26
+
27
+ interface RunReference {
28
+ runId: string;
29
+ parent: ParentIdentity;
30
+ }
31
+
32
+ interface SessionScan {
33
+ cost?: number;
34
+ nested: RunReference[];
35
+ }
36
+
37
+ export function collectBetter(
38
+ toolResults: AttributedToolResult[],
39
+ registryRoot = defaultRegistryRoot(),
40
+ ): CollectorSummary {
41
+ const queue: RunReference[] = [];
42
+ const parentCache = new Map<string, ParentIdentity | null>();
43
+ for (const result of toolResults) {
44
+ const parent = sessionIdentity(result.sessionFile, parentCache);
45
+ if (!parent) continue;
46
+ for (const runId of extractRunIds(result.toolName, result.content)) queue.push({ runId, parent });
47
+ }
48
+
49
+ const contributions = new Map<string, number>();
50
+ const processed = new Set<string>();
51
+ const attempted = new Set<string>();
52
+ while (queue.length > 0) {
53
+ const reference = queue.shift()!;
54
+ const attemptKey = `${reference.runId}\0${reference.parent.sessionId}\0${reference.parent.cwd}`;
55
+ if (attempted.has(attemptKey) || processed.has(reference.runId)) continue;
56
+ attempted.add(attemptKey);
57
+ if (!validRunMeta(registryRoot, reference)) continue;
58
+ const sessionFile = findRunSessionFile(registryRoot, reference.runId);
59
+ if (!sessionFile) continue;
60
+ const scan = scanRunSession(sessionFile, reference.runId);
61
+ if (!scan) continue;
62
+ processed.add(reference.runId);
63
+ if (scan.cost !== undefined) contributions.set(`better:${reference.runId}`, scan.cost);
64
+ queue.push(...scan.nested);
65
+ }
66
+
67
+ return { cost: sumContributions(contributions), count: contributions.size };
68
+ }
69
+
70
+ function defaultRegistryRoot(): string {
71
+ let temporaryRoot = os.tmpdir();
72
+ try {
73
+ temporaryRoot = fs.realpathSync(temporaryRoot);
74
+ } catch {
75
+ // An unavailable temp root simply produces no attributable runs.
76
+ }
77
+ return path.join(temporaryRoot, "pi-better-subagents");
78
+ }
79
+
80
+ function sessionIdentity(
81
+ sessionFile: string,
82
+ cache: Map<string, ParentIdentity | null>,
83
+ ): ParentIdentity | null {
84
+ if (cache.has(sessionFile)) return cache.get(sessionFile) ?? null;
85
+ const identity = readSessionHeader(sessionFile);
86
+ cache.set(sessionFile, identity);
87
+ return identity;
88
+ }
89
+
90
+ function extractRunIds(toolName: string, content: unknown): string[] {
91
+ const text = contentText(content);
92
+ const found = new Set<string>();
93
+ const referencedIds = new Set([...text.matchAll(/\bsa_[a-z0-9]+_[a-z0-9]+\b/gi)].map((match) => match[0]!));
94
+ if (toolName === "subagent_spawn") {
95
+ const firstLine = text.split(/\r?\n/, 1)[0] ?? "";
96
+ const match = /^Subagent launched: .*\bid=(sa_[a-z0-9]+_[a-z0-9]+) \(pid \d+\)\.$/i.exec(firstLine);
97
+ const runId = match?.[1];
98
+ return runId && referencedIds.size === 1 && referencedIds.has(runId) ? [runId] : [];
99
+ }
100
+ if (toolName === "subagent_spawn_batch") {
101
+ const lines = text.split(/\r?\n/);
102
+ const header = /^Batch .+ launched ([1-9]\d*) subagent\(s\):$/.exec(lines[0] ?? "");
103
+ const expected = Number(header?.[1]);
104
+ if (!header || !Number.isSafeInteger(expected) || lines.length < expected + 1) return [];
105
+ for (let index = 1; index <= expected; index += 1) {
106
+ const match = /^• .* → (sa_[a-z0-9]+_[a-z0-9]+)(?: · .*|\s*)$/i.exec(lines[index] ?? "");
107
+ if (!match?.[1] || !RUN_ID.test(match[1])) return [];
108
+ found.add(match[1]);
109
+ }
110
+ const remainder = lines.slice(expected + 1);
111
+ if (remainder.some((line) => !/^Failed \(\d+\): .+$/.test(line) && !/^Skipped \(capacity\): .+$/.test(line))) return [];
112
+ if (found.size !== expected || referencedIds.size !== found.size
113
+ || [...referencedIds].some((runId) => !found.has(runId))) return [];
114
+ }
115
+ return [...found];
116
+ }
117
+
118
+ function validRunMeta(registryRoot: string, reference: RunReference): boolean {
119
+ const runDirectory = containedRunDirectory(registryRoot, reference.runId);
120
+ if (!runDirectory) return false;
121
+ const metaPath = path.join(runDirectory, "meta.json");
122
+ try {
123
+ if (fs.lstatSync(metaPath).isSymbolicLink()) return false;
124
+ const resolvedMeta = fs.realpathSync(metaPath);
125
+ if (!resolvedMeta.startsWith(`${runDirectory}${path.sep}`)) return false;
126
+ } catch {
127
+ return false;
128
+ }
129
+ const meta = asRecord(readJsonFile(metaPath));
130
+ if (!meta || meta.id !== reference.runId || meta.sessionId !== reference.runId) return false;
131
+ if (typeof meta.status !== "string" || !RUN_STATUSES.has(meta.status)) return false;
132
+ const origin = asRecord(meta.callbackOrigin);
133
+ return origin?.sessionId === reference.parent.sessionId && origin.cwd === reference.parent.cwd;
134
+ }
135
+
136
+ function containedRunDirectory(registryRoot: string, runId: string): string | null {
137
+ const runsRoot = path.resolve(registryRoot, "runs");
138
+ const candidate = path.join(runsRoot, runId);
139
+ try {
140
+ if (fs.lstatSync(candidate).isSymbolicLink()) return null;
141
+ const resolved = fs.realpathSync(candidate);
142
+ return resolved.startsWith(`${runsRoot}${path.sep}`) && fs.statSync(resolved).isDirectory()
143
+ ? resolved
144
+ : null;
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ function findRunSessionFile(registryRoot: string, runId: string): string | null {
151
+ const sessionsRoot = path.resolve(registryRoot, "sessions");
152
+ const candidatePaths = new Set<string>();
153
+ collectJsonlFiles(path.join(sessionsRoot, runId), sessionsRoot, candidatePaths, true);
154
+ if (candidatePaths.size > MAX_SESSION_CANDIDATES) return null;
155
+ const matching = [...candidatePaths].filter((file) => readSessionHeader(file)?.sessionId === runId);
156
+ return matching.length === 1 ? matching[0]! : null;
157
+ }
158
+
159
+ function collectJsonlFiles(
160
+ candidate: string,
161
+ root: string,
162
+ found: Set<string>,
163
+ recurse: boolean,
164
+ ): void {
165
+ let stat: fs.Stats;
166
+ try {
167
+ stat = fs.lstatSync(candidate);
168
+ } catch {
169
+ return;
170
+ }
171
+ if (found.size > MAX_SESSION_CANDIDATES) return;
172
+ if (stat.isSymbolicLink()) return;
173
+ if (stat.isFile()) {
174
+ if (!candidate.endsWith(".jsonl")) return;
175
+ let resolved: string;
176
+ try {
177
+ resolved = fs.realpathSync(candidate);
178
+ } catch {
179
+ return;
180
+ }
181
+ if (resolved.startsWith(`${root}${path.sep}`)) found.add(resolved);
182
+ return;
183
+ }
184
+ if (!recurse || !stat.isDirectory()) return;
185
+ let names: string[];
186
+ try {
187
+ names = fs.readdirSync(candidate);
188
+ } catch {
189
+ return;
190
+ }
191
+ for (const name of names) {
192
+ if (found.size > MAX_SESSION_CANDIDATES) break;
193
+ collectJsonlFiles(path.join(candidate, name), root, found, true);
194
+ }
195
+ }
196
+
197
+ function readSessionHeader(file: string): ParentIdentity | null {
198
+ let identity: ParentIdentity | null = null;
199
+ visitJsonLines(file, (value, index) => {
200
+ if (index !== 0) return false;
201
+ identity = identityFromHeader(value);
202
+ return false;
203
+ }, 1);
204
+ return identity;
205
+ }
206
+
207
+ function identityFromHeader(value: unknown): ParentIdentity | null {
208
+ const header = asRecord(value);
209
+ return header?.type === "session" && typeof header.id === "string" && header.id.length > 0
210
+ && typeof header.cwd === "string" && header.cwd.length > 0
211
+ ? { sessionId: header.id, cwd: header.cwd }
212
+ : null;
213
+ }
214
+
215
+ function scanRunSession(file: string, expectedSessionId: string): SessionScan | null {
216
+ let validHeader = false;
217
+ let foundCost = false;
218
+ let cost = 0;
219
+ const nested: RunReference[] = [];
220
+ let parent: ParentIdentity | undefined;
221
+ const complete = visitJsonLines(file, (value, index) => {
222
+ if (index === 0) {
223
+ parent = identityFromHeader(value) ?? undefined;
224
+ if (!parent || parent.sessionId !== expectedSessionId) return false;
225
+ validHeader = true;
226
+ return true;
227
+ }
228
+ const record = asRecord(value);
229
+ if (!validHeader || record?.type !== "message") return true;
230
+ const message = asRecord(record.message);
231
+ if (message?.role === "assistant") {
232
+ const usage = asRecord(message.usage);
233
+ const costRecord = asRecord(usage?.cost);
234
+ const value = finiteNonNegative(costRecord?.total);
235
+ if (value !== undefined) {
236
+ foundCost = true;
237
+ cost += value;
238
+ }
239
+ }
240
+ if (message?.role === "toolResult" && typeof message.toolName === "string" && parent) {
241
+ for (const runId of extractRunIds(message.toolName, message.content)) nested.push({ runId, parent });
242
+ }
243
+ return true;
244
+ });
245
+ if (!complete || !validHeader) return null;
246
+ return { cost: foundCost ? cost : undefined, nested };
247
+ }
248
+
249
+ function visitJsonLines(
250
+ file: string,
251
+ visitor: (value: unknown, index: number) => boolean,
252
+ lineLimit = Number.POSITIVE_INFINITY,
253
+ ): boolean {
254
+ let descriptor: number;
255
+ try {
256
+ descriptor = fs.openSync(file, "r");
257
+ } catch {
258
+ return false;
259
+ }
260
+ const decoder = new StringDecoder("utf8");
261
+ const chunk = Buffer.allocUnsafe(READ_CHUNK_BYTES);
262
+ let pending = "";
263
+ let pendingBytes = 0;
264
+ let searchFrom = 0;
265
+ let index = 0;
266
+ let keepReading = true;
267
+ try {
268
+ while (keepReading && index < lineLimit) {
269
+ const bytes = fs.readSync(descriptor, chunk, 0, chunk.length, null);
270
+ if (bytes === 0) break;
271
+ const piece = decoder.write(chunk.subarray(0, bytes));
272
+ pending += piece;
273
+ pendingBytes += Buffer.byteLength(piece);
274
+ let newline = pending.indexOf("\n", searchFrom);
275
+ if (newline < 0) {
276
+ if (pendingBytes > MAX_SESSION_LINE_BYTES) return false;
277
+ searchFrom = pending.length;
278
+ continue;
279
+ }
280
+ while (newline >= 0 && index < lineLimit) {
281
+ const consumed = pending.slice(0, newline + 1);
282
+ const line = consumed.slice(0, -1).replace(/\r$/, "");
283
+ pending = pending.slice(newline + 1);
284
+ pendingBytes -= Buffer.byteLength(consumed);
285
+ searchFrom = 0;
286
+ if (!line.trim()) {
287
+ newline = pending.indexOf("\n");
288
+ continue;
289
+ }
290
+ if (Buffer.byteLength(line) > MAX_SESSION_LINE_BYTES) return false;
291
+ let value: unknown;
292
+ try {
293
+ value = JSON.parse(line);
294
+ } catch {
295
+ return false;
296
+ }
297
+ keepReading = visitor(value, index++);
298
+ if (!keepReading) break;
299
+ newline = pending.indexOf("\n");
300
+ }
301
+ if (newline < 0) searchFrom = pending.length;
302
+ }
303
+ pending += decoder.end();
304
+ if (keepReading && index < lineLimit && pending.trim()) {
305
+ if (Buffer.byteLength(pending) > MAX_SESSION_LINE_BYTES) return false;
306
+ try {
307
+ keepReading = visitor(JSON.parse(pending), index);
308
+ } catch {
309
+ return false;
310
+ }
311
+ }
312
+ return keepReading || index > 0;
313
+ } finally {
314
+ fs.closeSync(descriptor);
315
+ }
316
+ }