pi-daddy 0.43.1 → 0.44.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.
Files changed (221) hide show
  1. package/CHANGELOG.md +93 -31
  2. package/README.md +15 -0
  3. package/THIRD_PARTY_LICENSES/musl-COPYRIGHT.txt +193 -0
  4. package/THIRD_PARTY_LICENSES/zig-MIT.txt +21 -0
  5. package/THIRD_PARTY_NOTICES.md +9 -0
  6. package/dist/cli.d.ts +2 -1
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +10 -5
  9. package/dist/cli.js.map +1 -1
  10. package/dist/executors/activity-session.js +116 -65
  11. package/dist/executors/activity-session.js.map +1 -1
  12. package/dist/executors/captured-execution.d.ts +13 -0
  13. package/dist/executors/captured-execution.d.ts.map +1 -0
  14. package/dist/executors/captured-execution.js +53 -0
  15. package/dist/executors/captured-execution.js.map +1 -0
  16. package/dist/executors/child-final.d.ts +31 -0
  17. package/dist/executors/child-final.d.ts.map +1 -0
  18. package/dist/executors/child-final.js +197 -0
  19. package/dist/executors/child-final.js.map +1 -0
  20. package/dist/executors/executor.d.ts +2 -1
  21. package/dist/executors/executor.d.ts.map +1 -1
  22. package/dist/executors/executor.js +12 -0
  23. package/dist/executors/executor.js.map +1 -1
  24. package/dist/executors/owned-worker.d.ts +18 -0
  25. package/dist/executors/owned-worker.d.ts.map +1 -0
  26. package/dist/executors/owned-worker.js +295 -0
  27. package/dist/executors/owned-worker.js.map +1 -0
  28. package/dist/executors/worker-artifact.d.ts +3 -0
  29. package/dist/executors/worker-artifact.d.ts.map +1 -0
  30. package/dist/executors/worker-artifact.js +3 -0
  31. package/dist/executors/worker-artifact.js.map +1 -0
  32. package/dist/governance/approval-prompt.d.ts +4 -3
  33. package/dist/governance/approval-prompt.d.ts.map +1 -1
  34. package/dist/governance/approval-prompt.js +13 -6
  35. package/dist/governance/approval-prompt.js.map +1 -1
  36. package/dist/governance/captured-worker-record.d.ts +9 -0
  37. package/dist/governance/captured-worker-record.d.ts.map +1 -0
  38. package/dist/governance/captured-worker-record.js +123 -0
  39. package/dist/governance/captured-worker-record.js.map +1 -0
  40. package/dist/governance/lease-record.d.ts +4 -0
  41. package/dist/governance/lease-record.d.ts.map +1 -1
  42. package/dist/governance/lease-record.js +19 -2
  43. package/dist/governance/lease-record.js.map +1 -1
  44. package/dist/governance/ledger-events.d.ts +5 -5
  45. package/dist/governance/ledger-events.d.ts.map +1 -1
  46. package/dist/governance/ledger-v3-validation.js +1 -1
  47. package/dist/governance/ledger-v3-validation.js.map +1 -1
  48. package/dist/governance/workspace-acceptance.d.ts +3 -1
  49. package/dist/governance/workspace-acceptance.d.ts.map +1 -1
  50. package/dist/governance/workspace-acceptance.js +21 -5
  51. package/dist/governance/workspace-acceptance.js.map +1 -1
  52. package/dist/governance/workspace-lease.d.ts.map +1 -1
  53. package/dist/governance/workspace-lease.js +44 -1
  54. package/dist/governance/workspace-lease.js.map +1 -1
  55. package/dist/kernel/authority-text.d.ts +3 -0
  56. package/dist/kernel/authority-text.d.ts.map +1 -0
  57. package/dist/kernel/authority-text.js +22 -0
  58. package/dist/kernel/authority-text.js.map +1 -0
  59. package/dist/kernel/bounded-read-failures.d.ts +9 -0
  60. package/dist/kernel/bounded-read-failures.d.ts.map +1 -0
  61. package/dist/kernel/bounded-read-failures.js +33 -0
  62. package/dist/kernel/bounded-read-failures.js.map +1 -0
  63. package/dist/kernel/bounded-read-sync.d.ts +24 -0
  64. package/dist/kernel/bounded-read-sync.d.ts.map +1 -0
  65. package/dist/kernel/bounded-read-sync.js +75 -0
  66. package/dist/kernel/bounded-read-sync.js.map +1 -0
  67. package/dist/kernel/bounded-read.d.ts +29 -2
  68. package/dist/kernel/bounded-read.d.ts.map +1 -1
  69. package/dist/kernel/bounded-read.js +98 -9
  70. package/dist/kernel/bounded-read.js.map +1 -1
  71. package/dist/kernel/capacity.d.ts +36 -0
  72. package/dist/kernel/capacity.d.ts.map +1 -0
  73. package/dist/kernel/capacity.js +94 -0
  74. package/dist/kernel/capacity.js.map +1 -0
  75. package/dist/kernel/captured-worker-contract.d.ts +38 -0
  76. package/dist/kernel/captured-worker-contract.d.ts.map +1 -0
  77. package/dist/kernel/captured-worker-contract.js +2 -0
  78. package/dist/kernel/captured-worker-contract.js.map +1 -0
  79. package/dist/kernel/catalog.d.ts +12 -3
  80. package/dist/kernel/catalog.d.ts.map +1 -1
  81. package/dist/kernel/catalog.js +28 -8
  82. package/dist/kernel/catalog.js.map +1 -1
  83. package/dist/kernel/chain.d.ts +1 -0
  84. package/dist/kernel/chain.d.ts.map +1 -1
  85. package/dist/kernel/chain.js.map +1 -1
  86. package/dist/kernel/definitions.d.ts +11 -1
  87. package/dist/kernel/definitions.d.ts.map +1 -1
  88. package/dist/kernel/definitions.js +6 -3
  89. package/dist/kernel/definitions.js.map +1 -1
  90. package/dist/kernel/fanout.d.ts +5 -25
  91. package/dist/kernel/fanout.d.ts.map +1 -1
  92. package/dist/kernel/fanout.js +15 -30
  93. package/dist/kernel/fanout.js.map +1 -1
  94. package/dist/kernel/grant-env.d.ts +0 -2
  95. package/dist/kernel/grant-env.d.ts.map +1 -1
  96. package/dist/kernel/grant-env.js +0 -1
  97. package/dist/kernel/grant-env.js.map +1 -1
  98. package/dist/kernel/model-preflight.d.ts +8 -0
  99. package/dist/kernel/model-preflight.d.ts.map +1 -1
  100. package/dist/kernel/model-preflight.js +23 -0
  101. package/dist/kernel/model-preflight.js.map +1 -1
  102. package/dist/kernel/selected-definitions.d.ts +15 -0
  103. package/dist/kernel/selected-definitions.d.ts.map +1 -0
  104. package/dist/kernel/selected-definitions.js +146 -0
  105. package/dist/kernel/selected-definitions.js.map +1 -0
  106. package/dist/kernel/skill-packages.d.ts +4 -2
  107. package/dist/kernel/skill-packages.d.ts.map +1 -1
  108. package/dist/kernel/skill-packages.js +34 -41
  109. package/dist/kernel/skill-packages.js.map +1 -1
  110. package/dist/kernel/skill-resources.d.ts.map +1 -1
  111. package/dist/kernel/skill-resources.js +8 -11
  112. package/dist/kernel/skill-resources.js.map +1 -1
  113. package/dist/kernel/workspace.d.ts +6 -3
  114. package/dist/kernel/workspace.d.ts.map +1 -1
  115. package/dist/kernel/workspace.js +10 -6
  116. package/dist/kernel/workspace.js.map +1 -1
  117. package/dist/products/dashboard-cli.js +1 -1
  118. package/dist/products/dashboard-cli.js.map +1 -1
  119. package/dist/products/dashboard-render.d.ts +1 -2
  120. package/dist/products/dashboard-render.d.ts.map +1 -1
  121. package/dist/products/dashboard-render.js +3 -1
  122. package/dist/products/dashboard-render.js.map +1 -1
  123. package/dist/products/dashboard-session-client.d.ts +1 -2
  124. package/dist/products/dashboard-session-client.d.ts.map +1 -1
  125. package/dist/products/dashboard-session-client.js.map +1 -1
  126. package/dist/products/episode-report.d.ts +12 -8
  127. package/dist/products/episode-report.d.ts.map +1 -1
  128. package/dist/products/episode-report.js +61 -12
  129. package/dist/products/episode-report.js.map +1 -1
  130. package/extensions/approvals.ts +1 -1
  131. package/extensions/chain-plan.ts +9 -3
  132. package/extensions/context-staging.ts +30 -35
  133. package/extensions/dashboard-session-server.ts +1 -2
  134. package/extensions/definition-describe.ts +44 -0
  135. package/extensions/definition-runtime.ts +89 -26
  136. package/extensions/delegate-chain.ts +273 -256
  137. package/extensions/delegation-activation.ts +1 -1
  138. package/extensions/delegation.ts +41 -53
  139. package/extensions/execute-child.ts +167 -134
  140. package/extensions/fanout-outcome.ts +1 -9
  141. package/extensions/grants-command.ts +2 -9
  142. package/extensions/grants.ts +106 -38
  143. package/extensions/init-command.ts +52 -12
  144. package/extensions/reload-environment.ts +21 -2
  145. package/extensions/retired-inputs.ts +7 -0
  146. package/extensions/run-delegation.ts +212 -265
  147. package/extensions/session-capacity.ts +53 -0
  148. package/extensions/session-environment.ts +5 -2
  149. package/extensions/session-model-prompt.ts +33 -32
  150. package/extensions/session-report.ts +28 -11
  151. package/extensions/session.ts +200 -157
  152. package/extensions/workspace-runtime.ts +2 -1
  153. package/native/README.md +25 -0
  154. package/native/linux-x64/worker +0 -0
  155. package/native/linux-x64/worker.sha256 +1 -0
  156. package/native/provenance.json +42 -0
  157. package/native/toolchain.json +18 -0
  158. package/native/worker.c +209 -0
  159. package/package.json +15 -7
  160. package/src/cli.ts +16 -6
  161. package/src/executors/activity-session.ts +120 -66
  162. package/src/executors/captured-execution.ts +61 -0
  163. package/src/executors/child-final.ts +190 -0
  164. package/src/executors/executor.ts +14 -1
  165. package/src/executors/owned-worker.ts +322 -0
  166. package/src/executors/worker-artifact.ts +2 -0
  167. package/src/governance/approval-prompt.ts +18 -8
  168. package/src/governance/captured-worker-record.ts +138 -0
  169. package/src/governance/lease-record.ts +21 -2
  170. package/src/governance/ledger-events.ts +5 -5
  171. package/src/governance/ledger-v3-validation.ts +1 -1
  172. package/src/governance/workspace-acceptance.ts +20 -4
  173. package/src/governance/workspace-lease.ts +59 -1
  174. package/src/kernel/authority-text.ts +17 -0
  175. package/src/kernel/bounded-read-failures.ts +42 -0
  176. package/src/kernel/bounded-read-sync.ts +87 -0
  177. package/src/kernel/bounded-read.ts +128 -11
  178. package/src/kernel/capacity.ts +123 -0
  179. package/src/kernel/captured-worker-contract.ts +29 -0
  180. package/src/kernel/catalog.ts +45 -16
  181. package/src/kernel/chain.ts +1 -0
  182. package/src/kernel/definitions.ts +11 -2
  183. package/src/kernel/fanout.ts +17 -32
  184. package/src/kernel/grant-env.ts +0 -3
  185. package/src/kernel/model-preflight.ts +29 -0
  186. package/src/kernel/selected-definitions.ts +156 -0
  187. package/src/kernel/skill-packages.ts +40 -38
  188. package/src/kernel/skill-resources.ts +8 -9
  189. package/src/kernel/workspace.ts +12 -5
  190. package/src/products/dashboard-cli.ts +1 -1
  191. package/src/products/dashboard-render.ts +6 -2
  192. package/src/products/dashboard-session-client.ts +1 -2
  193. package/src/products/episode-report.ts +80 -17
  194. package/dist/advisors/advisor.d.ts +0 -90
  195. package/dist/advisors/advisor.d.ts.map +0 -1
  196. package/dist/advisors/advisor.js +0 -154
  197. package/dist/advisors/advisor.js.map +0 -1
  198. package/dist/advisors/decider.d.ts +0 -88
  199. package/dist/advisors/decider.d.ts.map +0 -1
  200. package/dist/advisors/decider.js +0 -29
  201. package/dist/advisors/decider.js.map +0 -1
  202. package/dist/advisors/jev.d.ts +0 -41
  203. package/dist/advisors/jev.d.ts.map +0 -1
  204. package/dist/advisors/jev.js +0 -139
  205. package/dist/advisors/jev.js.map +0 -1
  206. package/dist/advisors/settings.d.ts +0 -49
  207. package/dist/advisors/settings.d.ts.map +0 -1
  208. package/dist/advisors/settings.js +0 -99
  209. package/dist/advisors/settings.js.map +0 -1
  210. package/dist/governance/episode-cost-gate.d.ts +0 -31
  211. package/dist/governance/episode-cost-gate.d.ts.map +0 -1
  212. package/dist/governance/episode-cost-gate.js +0 -112
  213. package/dist/governance/episode-cost-gate.js.map +0 -1
  214. package/extensions/advisor-session.ts +0 -67
  215. package/extensions/effort-advice.ts +0 -112
  216. package/extensions/pruning-advice.ts +0 -133
  217. package/src/advisors/advisor.ts +0 -246
  218. package/src/advisors/decider.ts +0 -74
  219. package/src/advisors/jev.ts +0 -163
  220. package/src/advisors/settings.ts +0 -115
  221. package/src/governance/episode-cost-gate.ts +0 -128
@@ -0,0 +1,156 @@
1
+ /** Selected Pi 1.0.4 resources, frozen once per extension instance. No independent resource scan. */
2
+ import { createHash } from "node:crypto";
3
+ import { dirname, join, resolve as resolvePath } from "node:path";
4
+ import { lstat, realpath } from "node:fs/promises";
5
+ import { BoundedReadCleanupError, readBoundedBytes } from "./bounded-read.ts";
6
+ import { ceilingForDefinition, parseSkillDefinition, type SkillDefinition } from "./definitions.ts";
7
+ import { resolve } from "./resolve.ts";
8
+ import type { CatalogEntry } from "./catalog.ts";
9
+ export interface SelectedCommand {
10
+ source?: string;
11
+ name: string;
12
+ sourceInfo?: { path?: string };
13
+ }
14
+ const phases = ["plan", "build", "review", "debug", "investigate"];
15
+ const hash = (value: string) => createHash("sha256").update(value, "utf8").digest("hex");
16
+ const object = (x: unknown): x is Record<string, unknown> => !!x && typeof x === "object" && !Array.isArray(x);
17
+ const exact = (x: Record<string, unknown>, keys: string[]) =>
18
+ Object.keys(x).sort().join(",") === [...keys].sort().join(",");
19
+ async function read(path: string): Promise<string> {
20
+ if (!(await lstat(path)).isFile()) throw Error(`nonregular selected resource: ${path}`);
21
+ const result = await readBoundedBytes(path, { maxBytes: 1024 * 1024, timeoutMs: 3000 });
22
+ if (!result.ok) throw Error(result.detail);
23
+ return new TextDecoder("utf-8", { fatal: true }).decode(result.bytes);
24
+ }
25
+ async function principalBinding(path: string, inline: SkillDefinition, skillText: string): Promise<SkillDefinition> {
26
+ const phase = inline.name;
27
+ const marked = inline.metadata?.["principal-package"] === "principal-pi-skills";
28
+ if (!phases.includes(phase) || path !== join(dirname(dirname(path)), phase, "SKILL.md")) {
29
+ if (marked) throw Error("marked Principal skill has a noncanonical phase or path");
30
+ return inline;
31
+ }
32
+ const root = dirname(dirname(path));
33
+ let packageText: string;
34
+ try {
35
+ packageText = await read(join(root, "package.json"));
36
+ } catch (error) {
37
+ if ((error as NodeJS.ErrnoException).code === "ENOENT" && !marked) return inline;
38
+ throw error;
39
+ }
40
+ const pkg: unknown = JSON.parse(packageText);
41
+ if (!object(pkg) || pkg.name !== "principal-pi-skills") {
42
+ if (marked) throw Error("marked Principal skill has missing or wrong package identity");
43
+ return inline;
44
+ }
45
+ const manifest: unknown = JSON.parse(await read(join(root, "principal-agents.json")));
46
+ if (
47
+ !object(manifest) ||
48
+ !exact(manifest, ["version", "package", "bindings"]) ||
49
+ manifest.version !== 1 ||
50
+ manifest.package !== "principal-pi-skills" ||
51
+ !object(manifest.bindings) ||
52
+ !exact(manifest.bindings, phases)
53
+ )
54
+ throw Error("invalid Principal binding manifest");
55
+ for (const name of phases) {
56
+ const row = manifest.bindings[name];
57
+ if (
58
+ !object(row) ||
59
+ !exact(row, ["skill", "agent", "skillSha256", "agentSha256"]) ||
60
+ row.skill !== `${name}/SKILL.md` ||
61
+ row.agent !== `agents/principal-${name}.md` ||
62
+ typeof row.skillSha256 !== "string" ||
63
+ !/^[a-f0-9]{64}$/.test(row.skillSha256) ||
64
+ typeof row.agentSha256 !== "string" ||
65
+ !/^[a-f0-9]{64}$/.test(row.agentSha256)
66
+ )
67
+ throw Error("invalid Principal binding row");
68
+ }
69
+ const row = manifest.bindings[phase] as Record<string, string>;
70
+ const agentPath = join(root, row.agent);
71
+ if (
72
+ (await realpath(agentPath)) !== join(await realpath(root), row.agent) ||
73
+ (await realpath(path)) !== join(await realpath(root), row.skill)
74
+ )
75
+ throw Error("Principal binding path escaped its selected package");
76
+ const agentText = await read(agentPath);
77
+ if (hash(skillText) !== row.skillSha256 || hash(agentText) !== row.agentSha256)
78
+ throw Error("Principal binding hash mismatch");
79
+ const agent = parseSkillDefinition(agentPath, agentText);
80
+ if (!agent) throw Error("Principal delegated definition is malformed");
81
+ const inlineCeiling = ceilingForDefinition(inline),
82
+ delegatedCeiling = ceilingForDefinition(agent);
83
+ if (
84
+ inlineCeiling.undeclared ||
85
+ delegatedCeiling.undeclared ||
86
+ inlineCeiling.patterns.length ||
87
+ delegatedCeiling.patterns.length
88
+ )
89
+ throw Error("Principal binding needs two declared pattern-free ceilings");
90
+ const ceiling = resolve({
91
+ requested: delegatedCeiling.capabilities,
92
+ parentGrant: inlineCeiling.capabilities,
93
+ subsumption: false,
94
+ }).effective;
95
+ const header = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/.exec(agentText)!;
96
+ return {
97
+ ...agent,
98
+ name: phase,
99
+ source: agentPath,
100
+ sourceHash: hash(agentText),
101
+ selectedSkillHash: hash(skillText),
102
+ body: agentText.slice(header[0].length),
103
+ allowedTools: ceiling.join(","),
104
+ runtimePreferences: agent.runtimePreferences ?? inline.runtimePreferences,
105
+ ...(typeof pkg.version === "string" ? { packageVersion: pkg.version } : {}),
106
+ binding: Object.freeze({ package: "principal-pi-skills", phase }),
107
+ };
108
+ }
109
+ export async function selectedDefinitions(commands: readonly SelectedCommand[]): Promise<{
110
+ definitions: Map<string, SkillDefinition>;
111
+ skills: CatalogEntry[];
112
+ skips: string[];
113
+ }> {
114
+ const definitions = new Map<string, SkillDefinition>();
115
+ const skills: CatalogEntry[] = [],
116
+ skips: string[] = [],
117
+ seen = new Set<string>();
118
+ for (const command of commands) {
119
+ if (command.source !== "skill" || !command.name.startsWith("skill:")) continue;
120
+ const name = command.name.slice(6);
121
+ if (seen.has(name)) {
122
+ definitions.delete(name);
123
+ skips.push(`${name}: duplicate selected resource`);
124
+ continue;
125
+ }
126
+ seen.add(name);
127
+ try {
128
+ if (!command.sourceInfo?.path) throw Error("selected resource lacks a public source path");
129
+ const path = resolvePath(command.sourceInfo.path);
130
+ const text = await read(path);
131
+ const parsed = parseSkillDefinition(path, text);
132
+ if (!parsed || parsed.name !== name) throw Error("selected name/path does not match definition");
133
+ const bound = await principalBinding(path, parsed, text);
134
+ const definitionId = hash(
135
+ JSON.stringify({
136
+ name,
137
+ source: bound.source,
138
+ sourceHash: bound.sourceHash,
139
+ selectedSkillHash: bound.selectedSkillHash,
140
+ body: bound.body,
141
+ allowedTools: bound.allowedTools,
142
+ runtimePreferences: bound.runtimePreferences,
143
+ binding: bound.binding,
144
+ packageVersion: bound.packageVersion,
145
+ }),
146
+ );
147
+ if (bound.metadata) Object.freeze(bound.metadata);
148
+ definitions.set(name, Object.freeze({ ...bound, definitionId }));
149
+ skills.push({ capability: `skill:${name}`, kind: "skill", source: path });
150
+ } catch (error) {
151
+ if (error instanceof BoundedReadCleanupError) throw error;
152
+ skips.push(`${name}: ${String(error)}`);
153
+ }
154
+ }
155
+ return { definitions, skills: skills.filter((s) => definitions.has(s.capability.slice(6))), skips };
156
+ }
@@ -5,7 +5,8 @@
5
5
  * Names, ceilings and bytes are checked before they can enter the generated shell grant.
6
6
  */
7
7
 
8
- import { readdir, readFile, realpath } from "node:fs/promises";
8
+ import { readdir, realpath } from "node:fs/promises";
9
+ import { readOptionalAuthorityText } from "./authority-text.ts";
9
10
  import { readBoundedBytes } from "./bounded-read.ts";
10
11
  import { homedir } from "node:os";
11
12
  import { join, resolve, sep } from "node:path";
@@ -143,7 +144,11 @@ function refusalFor(skill: DiscoveredSkill): RefusedSkill | null {
143
144
  }
144
145
 
145
146
  /** One `pi.skills` entry: a directory holding `SKILL.md`, or a `.md` file — the same two shapes pi allows. */
146
- async function readSkill(packageDir: string, entry: string): Promise<DiscoveredSkill | "not-utf8" | null> {
147
+ async function readSkill(
148
+ packageDir: string,
149
+ entry: string,
150
+ reader: typeof readBoundedBytes,
151
+ ): Promise<DiscoveredSkill | "not-utf8" | null> {
147
152
  const target = resolve(packageDir, entry);
148
153
  // A manifest is data from another package, so an entry escaping its own directory is refused rather than
149
154
  // followed. **`realpath`, not a lexical prefix test** (R-80): `resolve()` normalises `..` and knows
@@ -158,7 +163,7 @@ async function readSkill(packageDir: string, entry: string): Promise<DiscoveredS
158
163
  // `loadDefinitions` and accepted here, so `pi-daddy init` wrote `agent:advice` into the operator's grant
159
164
  // for a definition the session could never load, and the eventual `delegate` said only
160
165
  // `unknown agent "advice"`. An unreadable entry lands in `unreadable`, which `init` already prints.
161
- const read = await readBoundedBytes(path, {
166
+ const read = await reader(path, {
162
167
  maxBytes: DEFINITION_MAX_BYTES,
163
168
  timeoutMs: DEFINITION_READ_TIMEOUT_MS,
164
169
  });
@@ -180,13 +185,14 @@ async function readSkill(packageDir: string, entry: string): Promise<DiscoveredS
180
185
  }
181
186
 
182
187
  /** Read one installed package, if it declares skills. `null` means "not a skill package", not an error. */
183
- export async function readSkillPackage(packageDir: string): Promise<SkillPackage | null> {
188
+ export async function readSkillPackage(
189
+ packageDir: string,
190
+ reader: typeof readBoundedBytes = readBoundedBytes,
191
+ ): Promise<SkillPackage | null> {
184
192
  let manifest: { name?: string; version?: string; pi?: { skills?: unknown } };
185
- try {
186
- manifest = JSON.parse(await readFile(join(packageDir, "package.json"), "utf8"));
187
- } catch {
188
- return null;
189
- }
193
+ const text = await readOptionalAuthorityText(join(packageDir, "package.json"));
194
+ if (text === undefined) return null;
195
+ manifest = JSON.parse(text);
190
196
  const declared = manifest.pi?.skills;
191
197
  if (!Array.isArray(declared) || declared.length === 0) return null;
192
198
 
@@ -195,7 +201,7 @@ export async function readSkillPackage(packageDir: string): Promise<SkillPackage
195
201
  const refused: RefusedSkill[] = [];
196
202
  for (const entry of declared) {
197
203
  if (typeof entry !== "string") continue;
198
- const skill = await readSkill(packageDir, entry);
204
+ const skill = await readSkill(packageDir, entry, reader);
199
205
  if (skill === null) {
200
206
  unreadable.push(entry);
201
207
  } else if (skill === "not-utf8") {
@@ -243,7 +249,11 @@ export function skillPackageRoots(cwd: string): string[] {
243
249
  return [join(cwd, "node_modules"), join(agentDir, "npm", "node_modules")];
244
250
  }
245
251
 
246
- export async function discoverSkillPackages(cwd: string): Promise<SkillPackage[]> {
252
+ /** The optional reader is a trusted per-loader dependency, never package metadata or requester input. */
253
+ export async function discoverSkillPackages(
254
+ cwd: string,
255
+ reader: typeof readBoundedBytes = readBoundedBytes,
256
+ ): Promise<SkillPackage[]> {
247
257
  const resolved = await resolveSkillResources(cwd);
248
258
  const packages: SkillPackage[] = [];
249
259
  const seenSkills = new Set<string>();
@@ -260,15 +270,12 @@ export async function discoverSkillPackages(cwd: string): Promise<SkillPackage[]
260
270
  }),
261
271
  );
262
272
  for (const root of configuredRoots) {
263
- try {
264
- configuredNames.add(JSON.parse(await readFile(join(root, "package.json"), "utf8")).name);
265
- } catch {
266
- /* absent */
267
- }
273
+ const text = await readOptionalAuthorityText(join(root, "package.json"));
274
+ if (text !== undefined) configuredNames.add(JSON.parse(text).name);
268
275
  }
269
276
  for (const resource of resolved.skills) {
270
277
  // Same bound, same reason: this is the third `SKILL.md` reader and it feeds `planInit`.
271
- const resourceRead = await readBoundedBytes(resource.path, {
278
+ const resourceRead = await reader(resource.path, {
272
279
  maxBytes: DEFINITION_MAX_BYTES,
273
280
  timeoutMs: DEFINITION_READ_TIMEOUT_MS,
274
281
  });
@@ -281,12 +288,11 @@ export async function discoverSkillPackages(cwd: string): Promise<SkillPackage[]
281
288
  let name = resource.metadata.source;
282
289
  let version = "local";
283
290
  if (resource.metadata.origin === "package" && resource.metadata.baseDir) {
284
- try {
285
- const manifest = JSON.parse(await readFile(join(resource.metadata.baseDir, "package.json"), "utf8"));
291
+ const text = await readOptionalAuthorityText(join(resource.metadata.baseDir, "package.json"));
292
+ if (text !== undefined) {
293
+ const manifest = JSON.parse(text);
286
294
  name = manifest.name ?? name;
287
295
  version = manifest.version ?? version;
288
- } catch {
289
- /* the resource can be used without optional display metadata */
290
296
  }
291
297
  } else name = `${resource.metadata.scope} skills`;
292
298
  let pkg = packages.find((p) => p.name === name && p.version === version);
@@ -313,7 +319,7 @@ export async function discoverSkillPackages(cwd: string): Promise<SkillPackage[]
313
319
  const seenNames = new Set(packages.map((p) => p.name));
314
320
  for (const dir of dirs) {
315
321
  if (configuredRoots.has(resolve(dir))) continue;
316
- const found = await readSkillPackage(dir);
322
+ const found = await readSkillPackage(dir, reader);
317
323
  if (!found || configuredNames.has(found.name) || seenNames.has(found.name)) continue;
318
324
  seenNames.add(found.name);
319
325
  found.skills = found.skills.filter((skill) => !runtimeNames.has(skill.definition.name));
@@ -324,23 +330,19 @@ export async function discoverSkillPackages(cwd: string): Promise<SkillPackage[]
324
330
 
325
331
  /** Append every package directory under one `node_modules`, scoped packages included. */
326
332
  async function collectFrom(root: string, dirs: string[]): Promise<void> {
327
- let entries: string[];
333
+ let entries;
328
334
  try {
329
- entries = await readdir(root);
330
- } catch {
331
- return; // a root that does not exist is a normal state, not a failure
335
+ entries = await readdir(root, { withFileTypes: true });
336
+ } catch (error) {
337
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
338
+ throw error;
332
339
  }
333
-
334
- for (const entry of entries.sort()) {
335
- if (entry.startsWith(".")) continue; // .bin, .package-lock.json
336
- if (entry.startsWith("@")) {
337
- try {
338
- for (const scoped of (await readdir(join(root, entry))).sort()) dirs.push(join(root, entry, scoped));
339
- } catch {
340
- continue;
341
- }
342
- } else {
343
- dirs.push(join(root, entry));
344
- }
340
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
341
+ if (entry.name.startsWith(".") || (!entry.isDirectory() && !entry.isSymbolicLink())) continue;
342
+ if (entry.name.startsWith("@")) {
343
+ const scoped = await readdir(join(root, entry.name), { withFileTypes: true });
344
+ for (const child of scoped.sort((a, b) => a.name.localeCompare(b.name)))
345
+ if (child.isDirectory() || child.isSymbolicLink()) dirs.push(join(root, entry.name, child.name));
346
+ } else dirs.push(join(root, entry.name));
345
347
  }
346
348
  }
@@ -1,5 +1,6 @@
1
1
  /** Read Pi's configured resource surface without locks, installs, extension execution or model calls. */
2
- import { readFileSync, statSync } from "node:fs";
2
+ import { statSync } from "node:fs";
3
+ import { readOptionalAuthorityText } from "./authority-text.ts";
3
4
  import { join } from "node:path";
4
5
  import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
5
6
  import { piProjectDir } from "./project-paths.ts";
@@ -21,17 +22,15 @@ export function skillResourceName(path: string): string {
21
22
 
22
23
  export async function resolveSkillResources(cwd: string): Promise<SkillResources> {
23
24
  const agentDir = getAgentDir();
25
+ // Read before Pi's synchronous storage callback: only known absence receives its default.
26
+ const texts = {
27
+ global: await readOptionalAuthorityText(join(agentDir, "settings.json")),
28
+ project: await readOptionalAuthorityText(join(piProjectDir(cwd), "settings.json")),
29
+ };
24
30
  const settingsManager = SettingsManager.fromStorage({
25
31
  withLock(scope, read) {
26
- const path = scope === "global" ? join(agentDir, "settings.json") : join(piProjectDir(cwd), "settings.json");
27
- let text: string | undefined;
28
- try {
29
- text = readFileSync(path, "utf8");
30
- } catch (error) {
31
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
32
- }
33
32
  // This storage is intentionally read-only: no lock files or migrated settings are persisted.
34
- read(text);
33
+ read(texts[scope]);
35
34
  },
36
35
  });
37
36
  const errors = settingsManager.drainErrors();
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { execFile } from "node:child_process";
3
3
  import { once } from "node:events";
4
4
  import { mkdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
5
- import { readBoundedFile } from "./bounded-read.ts";
5
+ import { readBoundedFile, BoundedReadCleanupError } from "./bounded-read.ts";
6
6
  import { homedir } from "node:os";
7
7
  import { isAbsolute, join } from "node:path";
8
8
  import { promisify } from "node:util";
@@ -59,13 +59,17 @@ const REGISTRY_READ_TIMEOUT_MS = 2_000;
59
59
  /** A registry is an operator-authored JSON file; anything approaching this is not one. */
60
60
  const REGISTRY_MAX_BYTES = 1 << 20;
61
61
 
62
- export async function loadWorkspaceRegistry(path: string): Promise<WorkspaceRegistryFile> {
62
+ /** The optional reader is a trusted loader dependency, never registry/request input. */
63
+ export async function loadWorkspaceRegistry(
64
+ path: string,
65
+ readFile: typeof readBoundedFile = readBoundedFile,
66
+ ): Promise<WorkspaceRegistryFile> {
63
67
  // The guards this call carries — non-blocking open, every check on the held descriptor, a deadline between
64
68
  // chunks, the size bound checked twice — were worked out HERE and now live in `bounded-read.ts`, because a
65
69
  // second session-start reader went on using a bare `readFile` rather than copying them. The mapping from a
66
70
  // reason to a refusal stays here: only this caller knows that an unreadable registry is a governance
67
71
  // refusal naming the file, and its messages are unchanged.
68
- const read = await readBoundedFile(path, {
72
+ const read = await readFile(path, {
69
73
  maxBytes: REGISTRY_MAX_BYTES,
70
74
  timeoutMs: REGISTRY_READ_TIMEOUT_MS,
71
75
  });
@@ -151,7 +155,8 @@ export async function loadWorkspaceRegistry(path: string): Promise<WorkspaceRegi
151
155
  }
152
156
 
153
157
  /**
154
- * The registered workspace ids, for `planInit` to scaffold and `/grants` to list. `[]` when there is no registry or it is broken.
158
+ * The registered workspace ids, for `planInit` to scaffold and `/grants` to list.
159
+ * No registry or malformed/unreadable data yields []; physical cleanup failure still rejects, with its owner.
155
160
  *
156
161
  * Fails SOFT, and only because nothing here is an authority: this decides which ids appear as COMMENTS in a
157
162
  * generated file. `loadWorkspaceRegistry` throws a GovernanceRefusal naming the file, and that refusal is
@@ -172,11 +177,13 @@ export async function loadWorkspaceRegistry(path: string): Promise<WorkspaceRegi
172
177
  export async function registeredWorkspaceIds(
173
178
  registryPath = process.env[ENV_WORKSPACE_REGISTRY],
174
179
  onRefusal?: (reason: string) => void,
180
+ loadRegistry: typeof loadWorkspaceRegistry = loadWorkspaceRegistry,
175
181
  ): Promise<string[]> {
176
182
  if (!registryPath) return [];
177
183
  try {
178
- return Object.keys((await loadWorkspaceRegistry(registryPath)).workspaces).sort();
184
+ return Object.keys((await loadRegistry(registryPath)).workspaces).sort();
179
185
  } catch (error) {
186
+ if (error instanceof BoundedReadCleanupError) throw error;
180
187
  onRefusal?.(error instanceof Error ? error.message : String(error));
181
188
  return [];
182
189
  }
@@ -144,7 +144,7 @@ export async function dashboardFrame(options: DashboardFrameOptions): Promise<st
144
144
  details: options.details,
145
145
  history: options.history,
146
146
  modelRows: options.session?.rows,
147
- episodeCost: options.session ? { cost: options.session.cost, ceiling: options.session.ceiling } : undefined,
147
+ episodeCost: options.session ? { cost: options.session.cost } : undefined,
148
148
  });
149
149
  return waiting ? `${rendered}\n\nwaiting for ledger ${ledgerPath}` : rendered;
150
150
  }
@@ -9,7 +9,7 @@ export interface DashboardRenderOptions {
9
9
  completedRoots?: number;
10
10
  completedChildren?: number;
11
11
  modelRows?: Array<{ definition: string; model: string; thinking: string; source: string }>;
12
- episodeCost?: { cost: number; ceiling: number };
12
+ episodeCost?: { cost: number | null };
13
13
  }
14
14
 
15
15
  const ACTIVE = new Set<DashboardState>(["authorised", "starting", "running"]);
@@ -217,7 +217,11 @@ export function renderDashboard(projection: DashboardProjection, options: Dashbo
217
217
  lines.push(`${clean(row.definition)} ${clean(row.model)} ${clean(row.thinking)} ${clean(row.source)}`);
218
218
  lines.push("edit: m <definition> <provider:model> <thinking> | m all <provider:model> <thinking>");
219
219
  if (options.episodeCost)
220
- lines.push(`episode cost $${options.episodeCost.cost} / $${options.episodeCost.ceiling} ceiling`);
220
+ lines.push(
221
+ options.episodeCost.cost === null
222
+ ? "episode cost unavailable (usage coverage partial)"
223
+ : `reported episode cost $${options.episodeCost.cost} (coverage partial)`,
224
+ );
221
225
  lines.push("");
222
226
  }
223
227
 
@@ -2,8 +2,7 @@ import { createConnection } from "node:net";
2
2
 
3
3
  export interface DashboardSessionSnapshot {
4
4
  rows: Array<{ definition: string; model: string; thinking: string; source: string }>;
5
- cost: number;
6
- ceiling: number;
5
+ cost: number | null;
7
6
  }
8
7
 
9
8
  export async function dashboardSessionRequest(
@@ -13,7 +13,13 @@ export interface EpisodeReportOptions {
13
13
  model?: string;
14
14
  groupBy?: EpisodeGroupBy;
15
15
  }
16
- export interface EpisodeReportRow {
16
+ export interface CostEvidence {
17
+ /** Null unless every recorded child has a terminal cost observation. Scope excludes unrecorded/root work. */
18
+ cost: number | null;
19
+ observedCost: number;
20
+ costCoverage: "complete" | "partial" | "unavailable";
21
+ }
22
+ export interface EpisodeReportRow extends CostEvidence {
17
23
  episode: string;
18
24
  started: string;
19
25
  definition: string;
@@ -26,7 +32,6 @@ export interface EpisodeReportRow {
26
32
  outputTokens: number;
27
33
  cacheReadTokens: number;
28
34
  reasoningTokens: number;
29
- cost: number;
30
35
  compactions: number;
31
36
  turns: number;
32
37
  durationMs: number;
@@ -34,20 +39,25 @@ export interface EpisodeReportRow {
34
39
  commit: string;
35
40
  outcome: "" | "positive" | "negative" | "unknown";
36
41
  }
37
- export interface EpisodeReportGroup {
42
+ export interface EpisodeReportGroup extends CostEvidence {
38
43
  value: string;
39
44
  count: number;
40
45
  inputTokens: number;
41
46
  outputTokens: number;
42
47
  cacheReadTokens: number;
43
48
  reasoningTokens: number;
44
- cost: number;
45
49
  }
46
50
  export interface EpisodeReport {
51
+ costScope: "recorded-child-executions";
47
52
  rows: EpisodeReportRow[];
48
53
  groups?: EpisodeReportGroup[];
49
54
  groupBy?: EpisodeGroupBy;
50
- totals?: { episodes: number; cost: number; tokens: number; p50EpisodeCost: number; p95EpisodeCost: number };
55
+ totals?: CostEvidence & {
56
+ episodes: number;
57
+ tokens: number;
58
+ p50EpisodeCost: number | null;
59
+ p95EpisodeCost: number | null;
60
+ };
51
61
  }
52
62
  interface EpisodeAccumulator {
53
63
  episode: string;
@@ -65,6 +75,7 @@ interface EpisodeAccumulator {
65
75
  cacheReadTokens: number;
66
76
  reasoningTokens: number;
67
77
  cost: number;
78
+ reportedCosts: number;
68
79
  compactions: number;
69
80
  turns: number;
70
81
  children: Set<string>;
@@ -103,6 +114,7 @@ function episodeOf(episodes: Map<string, EpisodeAccumulator>, id: string, at: nu
103
114
  cacheReadTokens: 0,
104
115
  reasoningTokens: 0,
105
116
  cost: 0,
117
+ reportedCosts: 0,
106
118
  compactions: 0,
107
119
  turns: 0,
108
120
  children: new Set(),
@@ -174,7 +186,11 @@ export function reportEpisodes(input: {
174
186
  episode.cacheReadTokens += number(usage.cacheRead);
175
187
  episode.reasoningTokens += number(usage.reasoning);
176
188
  }
177
- if (usage && object(usage.cost)) episode.cost += number(usage.cost.total);
189
+ const charge = usage && object(usage.cost) ? usage.cost.total : undefined;
190
+ if (typeof charge === "number" && Number.isFinite(charge) && charge >= 0) {
191
+ episode.cost += charge;
192
+ episode.reportedCosts++;
193
+ }
178
194
  episode.compactions += number(body.compactionCount);
179
195
  }
180
196
  const activity = readRecords(input.activityText);
@@ -203,7 +219,14 @@ export function reportEpisodes(input: {
203
219
  outputTokens: episode.outputTokens,
204
220
  cacheReadTokens: episode.cacheReadTokens,
205
221
  reasoningTokens: episode.reasoningTokens,
206
- cost: episode.cost,
222
+ cost: episode.children.size > 0 && episode.reportedCosts === episode.children.size ? episode.cost : null,
223
+ observedCost: episode.cost,
224
+ costCoverage:
225
+ episode.children.size > 0 && episode.reportedCosts === episode.children.size
226
+ ? "complete"
227
+ : episode.reportedCosts > 0
228
+ ? "partial"
229
+ : "unavailable",
207
230
  compactions: episode.compactions,
208
231
  turns: episode.turns,
209
232
  durationMs: Math.max(0, episode.endedMs - episode.startedMs),
@@ -237,36 +260,58 @@ export function reportEpisodes(input: {
237
260
  cacheReadTokens: 0,
238
261
  reasoningTokens: 0,
239
262
  cost: 0,
263
+ observedCost: 0,
264
+ costCoverage: "complete",
240
265
  };
266
+ const summary = summarizeCosts(group.count ? [group, row] : [row]);
241
267
  group.count += 1;
242
268
  group.inputTokens += row.inputTokens;
243
269
  group.outputTokens += row.outputTokens;
244
270
  group.cacheReadTokens += row.cacheReadTokens;
245
271
  group.reasoningTokens += row.reasoningTokens;
246
- group.cost += row.cost;
272
+ Object.assign(group, summary);
247
273
  groups.set(value, group);
248
274
  }
249
275
  return {
276
+ costScope: "recorded-child-executions",
250
277
  rows: [],
251
278
  groups: [...groups.values()].sort((a, b) => a.value.localeCompare(b.value)),
252
279
  groupBy: input.options.groupBy,
253
280
  };
254
281
  }
255
- const costs = rows.map((row) => row.cost);
282
+ const summary = summarizeCosts(rows);
283
+ const costs = rows.flatMap((row) => (row.cost === null ? [] : [row.cost]));
256
284
  return {
285
+ costScope: "recorded-child-executions",
257
286
  rows,
258
287
  totals: {
259
288
  episodes: rows.length,
260
- cost: costs.reduce((sum, value) => sum + value, 0),
289
+ ...summary,
261
290
  tokens: rows.reduce((sum, row) => sum + row.inputTokens + row.outputTokens, 0),
262
- p50EpisodeCost: percentile(costs, 0.5),
263
- p95EpisodeCost: percentile(costs, 0.95),
291
+ p50EpisodeCost: summary.cost === null ? null : percentile(costs, 0.5),
292
+ p95EpisodeCost: summary.cost === null ? null : percentile(costs, 0.95),
264
293
  },
265
294
  };
266
295
  }
267
296
 
297
+ /** Summaries never treat an unobserved charge as a measured zero. */
298
+ function summarizeCosts(values: readonly CostEvidence[]): CostEvidence {
299
+ const observedCost = values.reduce((sum, row) => sum + row.observedCost, 0);
300
+ const complete = values.length > 0 && values.every((row) => row.costCoverage === "complete");
301
+ return {
302
+ cost: complete ? observedCost : null,
303
+ observedCost,
304
+ costCoverage: complete
305
+ ? "complete"
306
+ : values.some((row) => row.costCoverage !== "unavailable")
307
+ ? "partial"
308
+ : "unavailable",
309
+ };
310
+ }
311
+ const costScope = "Cost coverage refers only to recorded child executions; root and unrecorded work are not measured.";
268
312
  const escapeCell = (value: unknown): string => String(value).replaceAll("|", "\\|").replaceAll("\n", " ");
269
- const cost = (value: number): string => value.toFixed(6).replace(/\.?0+$/, "") || "0";
313
+ const cost = (value: number | null): string =>
314
+ value === null ? "unavailable" : value.toFixed(6).replace(/\.?0+$/, "") || "0";
270
315
  function duration(ms: number): string {
271
316
  const seconds = Math.round(ms / 1000);
272
317
  const minutes = Math.floor(seconds / 60);
@@ -290,6 +335,8 @@ export function renderEpisodeReport(report: EpisodeReport, json: boolean): strin
290
335
  "cacheReadTokens",
291
336
  "reasoningTokens",
292
337
  "cost",
338
+ "observed cost",
339
+ "cost coverage",
293
340
  ];
294
341
  return `${table(
295
342
  headers,
@@ -301,8 +348,10 @@ export function renderEpisodeReport(report: EpisodeReport, json: boolean): strin
301
348
  group.cacheReadTokens,
302
349
  group.reasoningTokens,
303
350
  cost(group.cost),
351
+ cost(group.observedCost),
352
+ group.costCoverage,
304
353
  ]),
305
- )}\n`;
354
+ )}\n\n${costScope}\n`;
306
355
  }
307
356
  const headers = [
308
357
  "episode",
@@ -318,6 +367,8 @@ export function renderEpisodeReport(report: EpisodeReport, json: boolean): strin
318
367
  "cacheReadTokens",
319
368
  "reasoningTokens",
320
369
  "cost",
370
+ "observed cost",
371
+ "cost coverage",
321
372
  "compactions",
322
373
  "turns",
323
374
  "duration",
@@ -339,6 +390,8 @@ export function renderEpisodeReport(report: EpisodeReport, json: boolean): strin
339
390
  row.cacheReadTokens,
340
391
  row.reasoningTokens,
341
392
  cost(row.cost),
393
+ cost(row.observedCost),
394
+ row.costCoverage,
342
395
  row.compactions,
343
396
  row.turns,
344
397
  duration(row.durationMs),
@@ -348,10 +401,20 @@ export function renderEpisodeReport(report: EpisodeReport, json: boolean): strin
348
401
  ]);
349
402
  const totals = report.totals!;
350
403
  const totalsTable = table(
351
- ["episodes", "cost", "tokens", "p50 episode cost", "p95 episode cost"],
352
- [[totals.episodes, cost(totals.cost), totals.tokens, cost(totals.p50EpisodeCost), cost(totals.p95EpisodeCost)]],
404
+ ["episodes", "cost", "tokens", "p50 episode cost", "p95 episode cost", "observed cost", "cost coverage"],
405
+ [
406
+ [
407
+ totals.episodes,
408
+ cost(totals.cost),
409
+ totals.tokens,
410
+ cost(totals.p50EpisodeCost),
411
+ cost(totals.p95EpisodeCost),
412
+ cost(totals.observedCost),
413
+ totals.costCoverage,
414
+ ],
415
+ ],
353
416
  );
354
- return `${table(headers, rows)}\n\n${totalsTable}\n`;
417
+ return `${table(headers, rows)}\n\n${totalsTable}\n\n${costScope}\n`;
355
418
  }
356
419
 
357
420
  export function commitsFromGitLog(log: string): Map<string, string> {