pi-daddy 0.21.0 → 0.22.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 (73) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/README.md +14 -9
  3. package/contracts/ledger/v3/fixtures/capability-decision.json +3 -4
  4. package/contracts/ledger/v3/fixtures/check-receipt.json +3 -4
  5. package/contracts/ledger/v3/fixtures/child-lifecycle.json +3 -4
  6. package/contracts/ledger/v3/fixtures/workflow-fact.json +3 -4
  7. package/contracts/ledger/v3/fixtures/workspace-lease.json +3 -4
  8. package/contracts/ledger/v3/ledger-event.schema.json +38 -13
  9. package/dist/check-runner.d.ts.map +1 -1
  10. package/dist/check-runner.js +161 -149
  11. package/dist/check-runner.js.map +1 -1
  12. package/dist/correlation.d.ts +15 -4
  13. package/dist/correlation.d.ts.map +1 -1
  14. package/dist/correlation.js +40 -2
  15. package/dist/correlation.js.map +1 -1
  16. package/dist/delegation-approval.d.ts +2 -2
  17. package/dist/delegation-approval.d.ts.map +1 -1
  18. package/dist/delegation-approval.js +2 -0
  19. package/dist/delegation-approval.js.map +1 -1
  20. package/dist/finalization.d.ts +15 -0
  21. package/dist/finalization.d.ts.map +1 -0
  22. package/dist/finalization.js +65 -0
  23. package/dist/finalization.js.map +1 -0
  24. package/dist/git-identity.d.ts.map +1 -1
  25. package/dist/git-identity.js +18 -15
  26. package/dist/git-identity.js.map +1 -1
  27. package/dist/grant-store.d.ts +13 -0
  28. package/dist/grant-store.d.ts.map +1 -1
  29. package/dist/grant-store.js +48 -25
  30. package/dist/grant-store.js.map +1 -1
  31. package/dist/herdr-poll.js +1 -1
  32. package/dist/herdr-poll.js.map +1 -1
  33. package/dist/init.d.ts.map +1 -1
  34. package/dist/init.js +2 -6
  35. package/dist/init.js.map +1 -1
  36. package/dist/model-preflight.d.ts +8 -0
  37. package/dist/model-preflight.d.ts.map +1 -0
  38. package/dist/model-preflight.js +20 -0
  39. package/dist/model-preflight.js.map +1 -0
  40. package/dist/refusals.d.ts +1 -1
  41. package/dist/refusals.d.ts.map +1 -1
  42. package/dist/refusals.js +2 -0
  43. package/dist/refusals.js.map +1 -1
  44. package/dist/run-child.d.ts +1 -1
  45. package/dist/run-child.d.ts.map +1 -1
  46. package/dist/run-child.js +2 -2
  47. package/dist/run-child.js.map +1 -1
  48. package/dist/run-herdr.d.ts.map +1 -1
  49. package/dist/run-herdr.js +6 -5
  50. package/dist/run-herdr.js.map +1 -1
  51. package/extensions/chain-plan.ts +16 -0
  52. package/extensions/correlation-shape.ts +39 -0
  53. package/extensions/delegate-chain.ts +9 -14
  54. package/extensions/delegation.ts +3 -25
  55. package/extensions/execute-child.ts +8 -1
  56. package/extensions/grant-store-refusal.ts +36 -0
  57. package/extensions/grants.ts +6 -0
  58. package/extensions/run-delegation.ts +21 -2
  59. package/extensions/session.ts +15 -5
  60. package/extensions/stored-grant-session.ts +33 -0
  61. package/package.json +3 -1
  62. package/src/check-runner.ts +56 -44
  63. package/src/correlation.ts +55 -6
  64. package/src/delegation-approval.ts +4 -2
  65. package/src/finalization.ts +79 -0
  66. package/src/git-identity.ts +20 -16
  67. package/src/grant-store.ts +55 -25
  68. package/src/herdr-poll.ts +1 -1
  69. package/src/init.ts +5 -5
  70. package/src/model-preflight.ts +32 -0
  71. package/src/refusals.ts +2 -0
  72. package/src/run-child.ts +2 -2
  73. package/src/run-herdr.ts +6 -4
@@ -52,6 +52,12 @@ export interface StoredGrant {
52
52
  projectLedger: boolean;
53
53
  }
54
54
 
55
+ export type GrantStoreRefusalReason = "malformed" | "unsupported-version" | "unreadable" | "wrong-cwd";
56
+ export type StoredGrantState =
57
+ | { state: "absent" }
58
+ | { state: "valid"; stored: StoredGrant }
59
+ | { state: "refuse"; reason: GrantStoreRefusalReason };
60
+
55
61
  export interface SaveGrantOptions {
56
62
  projectLedger?: boolean;
57
63
  }
@@ -84,26 +90,36 @@ export function grantStorePath(cwd: string): string {
84
90
  * treats absent and invalid stores alike (R-175), so do not call that wider behavior fail-closed. The `cwd`
85
91
  * check is R-27's: a copied file describes a directory nobody authorised it for.
86
92
  */
87
- export function parseStoredGrant(text: string, cwd: string): StoredGrant | null {
93
+ export function parseStoredGrantState(text: string, cwd: string): StoredGrantState {
94
+ let parsed: { version?: unknown; cwd?: unknown; grant?: unknown; projectLedger?: unknown };
88
95
  try {
89
- const parsed = JSON.parse(text) as {
90
- version?: unknown;
91
- cwd?: unknown;
92
- grant?: unknown;
93
- projectLedger?: unknown;
94
- };
95
- if (parsed.version !== 1 && parsed.version !== 2) return null;
96
- if (parsed.cwd !== cwd) return null;
97
- if (!Array.isArray(parsed.grant)) return null;
98
- if (!parsed.grant.every((c) => typeof c === "string" && c.length > 0)) return null;
99
- if (parsed.version === 2 && parsed.projectLedger !== true) return null;
100
- return {
101
- grant: parsed.grant as Capability[],
102
- projectLedger: parsed.version === 2,
103
- };
96
+ parsed = JSON.parse(text) as typeof parsed;
104
97
  } catch {
105
- return null;
98
+ return { state: "refuse", reason: "malformed" };
99
+ }
100
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
101
+ return { state: "refuse", reason: "malformed" };
102
+ }
103
+ if (parsed.version !== 1 && parsed.version !== 2) {
104
+ return { state: "refuse", reason: "unsupported-version" };
105
+ }
106
+ if (parsed.cwd !== cwd) return { state: "refuse", reason: "wrong-cwd" };
107
+ if (!Array.isArray(parsed.grant)) return { state: "refuse", reason: "malformed" };
108
+ if (!parsed.grant.every((c) => typeof c === "string" && c.length > 0)) {
109
+ return { state: "refuse", reason: "malformed" };
110
+ }
111
+ if (parsed.version === 2 && parsed.projectLedger !== true) {
112
+ return { state: "refuse", reason: "malformed" };
106
113
  }
114
+ return {
115
+ state: "valid",
116
+ stored: { grant: parsed.grant as Capability[], projectLedger: parsed.version === 2 },
117
+ };
118
+ }
119
+
120
+ export function parseStoredGrant(text: string, cwd: string): StoredGrant | null {
121
+ const result = parseStoredGrantState(text, cwd);
122
+ return result.state === "valid" ? result.stored : null;
107
123
  }
108
124
 
109
125
  /** Compatibility view for callers that need only the capability ceiling. */
@@ -120,27 +136,41 @@ export function parseGrantFile(text: string, cwd: string): Capability[] | null {
120
136
  * decision it exists to inform, so the store would silently fail to grant delegation — the exact class of
121
137
  * defect R-38 and R-39 were.
122
138
  */
123
- export function loadStoredGrantSync(cwd: string): StoredGrant | null {
139
+ export function loadStoredGrantStateSync(cwd: string): StoredGrantState {
124
140
  try {
125
- return parseStoredGrant(readFileSync(grantStorePath(cwd), "utf8"), cwd);
126
- } catch {
127
- return null;
141
+ return parseStoredGrantState(readFileSync(grantStorePath(cwd), "utf8"), cwd);
142
+ } catch (error) {
143
+ return (error as NodeJS.ErrnoException).code === "ENOENT"
144
+ ? { state: "absent" }
145
+ : { state: "refuse", reason: "unreadable" };
128
146
  }
129
147
  }
130
148
 
149
+ export function loadStoredGrantSync(cwd: string): StoredGrant | null {
150
+ const result = loadStoredGrantStateSync(cwd);
151
+ return result.state === "valid" ? result.stored : null;
152
+ }
153
+
131
154
  export function loadGrantSync(cwd: string): Capability[] | null {
132
155
  return loadStoredGrantSync(cwd)?.grant ?? null;
133
156
  }
134
157
 
135
158
  /** Async twin, for callers that already have one. Same parser, so they cannot disagree. */
136
- export async function loadStoredGrant(cwd: string): Promise<StoredGrant | null> {
159
+ export async function loadStoredGrantState(cwd: string): Promise<StoredGrantState> {
137
160
  try {
138
- return parseStoredGrant(await readFile(grantStorePath(cwd), "utf8"), cwd);
139
- } catch {
140
- return null;
161
+ return parseStoredGrantState(await readFile(grantStorePath(cwd), "utf8"), cwd);
162
+ } catch (error) {
163
+ return (error as NodeJS.ErrnoException).code === "ENOENT"
164
+ ? { state: "absent" }
165
+ : { state: "refuse", reason: "unreadable" };
141
166
  }
142
167
  }
143
168
 
169
+ export async function loadStoredGrant(cwd: string): Promise<StoredGrant | null> {
170
+ const result = await loadStoredGrantState(cwd);
171
+ return result.state === "valid" ? result.stored : null;
172
+ }
173
+
144
174
  export async function loadGrant(cwd: string): Promise<Capability[] | null> {
145
175
  return (await loadStoredGrant(cwd))?.grant ?? null;
146
176
  }
package/src/herdr-poll.ts CHANGED
@@ -67,7 +67,7 @@ export async function waitForSettled(
67
67
  ): Promise<{ status?: string; timedOut?: boolean; aborted?: boolean; spawnError?: string }> {
68
68
  const interval = request.pollIntervalMs ?? POLL_INTERVAL_MS;
69
69
  // ADR-0032. The pane is read on every poll so the parent can show what the child is doing, rather than the
70
- // one word `delegate` for up to ten minutes.
70
+ // one word `delegate` for up to twenty minutes by default.
71
71
  //
72
72
  // No cross-poll state is kept, deliberately: the previous design remembered what it had reported so it could
73
73
  // send a diff, and that is what broke — see `tailLines`. A snapshot needs no memory.
package/src/init.ts CHANGED
@@ -26,6 +26,7 @@ import { mkdir, open, rm, writeFile } from "node:fs/promises";
26
26
  import { join } from "node:path";
27
27
  import { agentCapability, workspaceCapability } from "./capabilities.ts";
28
28
  import { ceilingForDefinition } from "./definitions.ts";
29
+ import { runWithFinalizers } from "./finalization.ts";
29
30
  import { ALWAYS_LIVE, assertGrantIsWritable, isLiveByDefault, renderGrantEnv, type GrantEnvSkill } from "./grant-env.ts";
30
31
  import { PI_BUILTIN_TOOLS } from "./pi-tools.ts";
31
32
  import type { Capability } from "./resolve.ts";
@@ -332,11 +333,10 @@ async function createUnlessPresent(path: string, content: string, outcome: InitO
332
333
  try {
333
334
  await mkdir(join(path, ".."), { recursive: true });
334
335
  const handle = await open(path, "wx");
335
- try {
336
- await handle.writeFile(content, "utf8");
337
- } finally {
338
- await handle.close();
339
- }
336
+ await runWithFinalizers(
337
+ () => handle.writeFile(content, "utf8"),
338
+ [{ label: "new-file handle cleanup failed", run: () => handle.close() }],
339
+ );
340
340
  outcome.written.push(path);
341
341
  } catch (error) {
342
342
  if ((error as { code?: string }).code === "EEXIST") outcome.kept.push(path);
@@ -0,0 +1,32 @@
1
+ import { refusal, type StructuredRefusal } from "./refusals.ts";
2
+
3
+ export const ENV_ALLOW_UNRESOLVED_MODELS = "PI_GRANTS_ALLOW_UNRESOLVED_MODELS";
4
+
5
+ export interface ModelCatalogue {
6
+ find(provider: string, modelId: string): unknown;
7
+ }
8
+
9
+ /** Resolve an explicit provider/id against pi's own session catalogue without probing credentials or network. */
10
+ export function preflightModel(
11
+ model: string | undefined,
12
+ catalogue: ModelCatalogue,
13
+ cache: Map<string, boolean>,
14
+ allowUnresolved: boolean,
15
+ ): StructuredRefusal | undefined {
16
+ if (model === undefined || allowUnresolved) return undefined;
17
+ const slash = model.indexOf("/");
18
+ const provider = slash > 0 ? model.slice(0, slash) : "";
19
+ const id = slash > 0 ? model.slice(slash + 1) : "";
20
+ let resolved = cache.get(model);
21
+ if (resolved === undefined) {
22
+ resolved = provider.length > 0 && id.length > 0 && catalogue.find(provider, id) !== undefined;
23
+ cache.set(model, resolved);
24
+ }
25
+ if (resolved) return undefined;
26
+ return refusal(
27
+ "MODEL_UNRESOLVED",
28
+ `grants: model ${model || "(empty)"} is not in pi's session catalogue; use provider/id from /model, ` +
29
+ `or set ${ENV_ALLOW_UNRESOLVED_MODELS}=1 to let pi attempt custom resolution`,
30
+ { model: model || "(empty)" },
31
+ );
32
+ }
package/src/refusals.ts CHANGED
@@ -11,6 +11,8 @@ export const REFUSAL_CODES = [
11
11
  "DEPTH_EXCEEDED",
12
12
  "FANOUT_EXCEEDED",
13
13
  "EXECUTOR_UNAVAILABLE",
14
+ "MODEL_UNRESOLVED",
15
+ "GRANT_STORE_INVALID",
14
16
  // Execution-phase outcomes. Without these, an external controller could distinguish a policy refusal
15
17
  // from an internal error but not a lost writer lease from a user pressing stop (R-103).
16
18
  "CHILD_TIMED_OUT",
package/src/run-child.ts CHANGED
@@ -111,8 +111,8 @@ export interface ChildRunResult {
111
111
 
112
112
  /** 1 MiB. A delegation returns a summary; anything larger is a runaway, not an answer. */
113
113
  export const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024;
114
- /** 10 minutes. Long enough for a real sub-agent task, short enough that a hang is not forever. */
115
- export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
114
+ /** 20 minutes. Long-running sub-agent work often exceeded ten; a hang remains bounded. */
115
+ export const DEFAULT_TIMEOUT_MS = 20 * 60 * 1000;
116
116
  /** Grace between SIGTERM and SIGKILL. A child that ignores SIGTERM must not make the timeout advisory. */
117
117
  export const DEFAULT_KILL_GRACE_MS = 5000;
118
118
 
package/src/run-herdr.ts CHANGED
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import { rm } from "node:fs/promises";
25
+ import { runWithFinalizers } from "./finalization.ts";
25
26
  import type { ChildRunResult } from "./run-child.ts";
26
27
  import { DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_TIMEOUT_MS } from "./run-child.ts";
27
28
  import { MAX_OPEN_PANES, markPaneSettled, trackPane, trimOpenPanes, untrackPane } from "./pane-reaper.ts";
@@ -293,7 +294,7 @@ export async function runHerdrPane(request: HerdrRunRequest): Promise<ChildRunRe
293
294
  // early `return` from a failed start or prompt — leaves it false, so `cleanup` closes the tab.
294
295
  let settledCleanly = false;
295
296
 
296
- try {
297
+ return runWithFinalizers(async () => {
297
298
  const started = await startAgent(exec, agentName, paneId, effectiveArgs, deadline);
298
299
  if (started.error) return { ...empty, spawnError: `herdr agent start failed: ${started.error}` };
299
300
 
@@ -352,9 +353,10 @@ export async function runHerdrPane(request: HerdrRunRequest): Promise<ChildRunRe
352
353
  timedOut: false,
353
354
  aborted: false,
354
355
  };
355
- } finally {
356
- await cleanup(settledCleanly);
357
- }
356
+ }, [{
357
+ label: "herdr pane finalizer failed",
358
+ run: () => cleanup(settledCleanly),
359
+ }]);
358
360
  }
359
361
 
360
362
  /**