pi-daddy 0.46.1 → 0.47.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 (106) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +41 -11
  3. package/contracts/ledger-record/v1/governance-event.schema.json +65 -3
  4. package/dist/governance/approval-prompt.d.ts +4 -0
  5. package/dist/governance/approval-prompt.d.ts.map +1 -1
  6. package/dist/governance/approval-prompt.js +59 -4
  7. package/dist/governance/approval-prompt.js.map +1 -1
  8. package/dist/governance/auto-mode-policy.d.ts +11 -0
  9. package/dist/governance/auto-mode-policy.d.ts.map +1 -0
  10. package/dist/governance/auto-mode-policy.js +309 -0
  11. package/dist/governance/auto-mode-policy.js.map +1 -0
  12. package/dist/governance/execution-retention.d.ts +3 -0
  13. package/dist/governance/execution-retention.d.ts.map +1 -1
  14. package/dist/governance/execution-retention.js +30 -0
  15. package/dist/governance/execution-retention.js.map +1 -1
  16. package/dist/governance/ledger-events.d.ts +14 -1
  17. package/dist/governance/ledger-events.d.ts.map +1 -1
  18. package/dist/governance/ledger-events.js +13 -0
  19. package/dist/governance/ledger-events.js.map +1 -1
  20. package/dist/governance/ledger-report.d.ts.map +1 -1
  21. package/dist/governance/ledger-report.js +5 -3
  22. package/dist/governance/ledger-report.js.map +1 -1
  23. package/dist/governance/ledger-v3-validation.d.ts.map +1 -1
  24. package/dist/governance/ledger-v3-validation.js +17 -3
  25. package/dist/governance/ledger-v3-validation.js.map +1 -1
  26. package/dist/governance/ledger.d.ts +1 -1
  27. package/dist/governance/ledger.d.ts.map +1 -1
  28. package/dist/governance/ledger.js +1 -1
  29. package/dist/governance/ledger.js.map +1 -1
  30. package/dist/kernel/approval.d.ts +1 -1
  31. package/dist/kernel/approval.d.ts.map +1 -1
  32. package/dist/kernel/approval.js +1 -1
  33. package/dist/kernel/approval.js.map +1 -1
  34. package/dist/kernel/auto-mode.d.ts +35 -0
  35. package/dist/kernel/auto-mode.d.ts.map +1 -0
  36. package/dist/kernel/auto-mode.js +29 -0
  37. package/dist/kernel/auto-mode.js.map +1 -0
  38. package/dist/kernel/delegate-types.d.ts +2 -0
  39. package/dist/kernel/delegate-types.d.ts.map +1 -1
  40. package/dist/kernel/delegate-types.js.map +1 -1
  41. package/dist/kernel/delegate.d.ts.map +1 -1
  42. package/dist/kernel/delegate.js +2 -2
  43. package/dist/kernel/delegate.js.map +1 -1
  44. package/dist/kernel/env-names.d.ts +2 -0
  45. package/dist/kernel/env-names.d.ts.map +1 -1
  46. package/dist/kernel/env-names.js +4 -0
  47. package/dist/kernel/env-names.js.map +1 -1
  48. package/dist/kernel/propagation.d.ts +5 -1
  49. package/dist/kernel/propagation.d.ts.map +1 -1
  50. package/dist/kernel/propagation.js +8 -2
  51. package/dist/kernel/propagation.js.map +1 -1
  52. package/dist/products/dashboard-cli.d.ts +6 -0
  53. package/dist/products/dashboard-cli.d.ts.map +1 -1
  54. package/dist/products/dashboard-cli.js +248 -52
  55. package/dist/products/dashboard-cli.js.map +1 -1
  56. package/dist/products/dashboard-display-controls.d.ts +2 -1
  57. package/dist/products/dashboard-display-controls.d.ts.map +1 -1
  58. package/dist/products/dashboard-display-controls.js +61 -0
  59. package/dist/products/dashboard-display-controls.js.map +1 -1
  60. package/dist/products/dashboard-render.d.ts +2 -0
  61. package/dist/products/dashboard-render.d.ts.map +1 -1
  62. package/dist/products/dashboard-render.js +2 -2
  63. package/dist/products/dashboard-render.js.map +1 -1
  64. package/dist/products/dashboard-screen.d.ts +52 -0
  65. package/dist/products/dashboard-screen.d.ts.map +1 -0
  66. package/dist/products/dashboard-screen.js +262 -0
  67. package/dist/products/dashboard-screen.js.map +1 -0
  68. package/dist/products/dashboard-session-client.d.ts +34 -2
  69. package/dist/products/dashboard-session-client.d.ts.map +1 -1
  70. package/dist/products/dashboard-session-client.js +51 -0
  71. package/dist/products/dashboard-session-client.js.map +1 -1
  72. package/dist/products/ecosystem-versions.d.ts +19 -0
  73. package/dist/products/ecosystem-versions.d.ts.map +1 -0
  74. package/dist/products/ecosystem-versions.js +2 -0
  75. package/dist/products/ecosystem-versions.js.map +1 -0
  76. package/extensions/approvals.ts +3 -2
  77. package/extensions/dashboard-session-server.ts +32 -4
  78. package/extensions/delegate-chain.ts +2 -0
  79. package/extensions/delegation-ledger.ts +10 -6
  80. package/extensions/ecosystem-versions.ts +166 -0
  81. package/extensions/grants-command.ts +33 -3
  82. package/extensions/grants.ts +28 -2
  83. package/extensions/reload-environment.ts +16 -1
  84. package/extensions/run-delegation.ts +49 -3
  85. package/extensions/session-auto-mode.ts +132 -0
  86. package/extensions/session.ts +47 -37
  87. package/package.json +2 -2
  88. package/src/governance/approval-prompt.ts +62 -4
  89. package/src/governance/auto-mode-policy.ts +297 -0
  90. package/src/governance/execution-retention.ts +24 -0
  91. package/src/governance/ledger-events.ts +22 -1
  92. package/src/governance/ledger-report.ts +5 -3
  93. package/src/governance/ledger-v3-validation.ts +17 -3
  94. package/src/governance/ledger.ts +1 -0
  95. package/src/kernel/approval.ts +1 -1
  96. package/src/kernel/auto-mode.ts +56 -0
  97. package/src/kernel/delegate-types.ts +2 -0
  98. package/src/kernel/delegate.ts +2 -1
  99. package/src/kernel/env-names.ts +4 -0
  100. package/src/kernel/propagation.ts +11 -2
  101. package/src/products/dashboard-cli.ts +255 -53
  102. package/src/products/dashboard-display-controls.ts +52 -0
  103. package/src/products/dashboard-render.ts +2 -2
  104. package/src/products/dashboard-screen.ts +339 -0
  105. package/src/products/dashboard-session-client.ts +60 -1
  106. package/src/products/ecosystem-versions.ts +18 -0
@@ -1,9 +1,11 @@
1
+ import { ENV_AUTO_MODE, ENV_AUTO_MODE_REF } from "../src/kernel/env-names.ts";
1
2
  import type { OwnerCapacity } from "./session-capacity.ts";
2
3
  import { GRANT_ENV_KEYS } from "../src/kernel/propagation.ts";
3
4
  import type { BoundedReadCleanupError } from "../src/kernel/bounded-read.ts";
4
5
 
5
6
  /** The root baseline and latest child publication for one real Pi session owner. */
6
7
  export interface ReloadLifecycle {
8
+ autoMode?: import("./session-auto-mode.ts").SessionAutoLifecycle;
7
9
  runtimeSnapshotUnsubscribe?: () => void;
8
10
  runtimeSnapshotGeneration?: object;
9
11
  runtimeSettlements?: Map<string, import("../src/governance/runtime-settlement.ts").RuntimeSettlement>;
@@ -59,7 +61,19 @@ function withRoot(root: Record<string, string | undefined>): NodeJS.ProcessEnv {
59
61
 
60
62
  /** Factory-time state is provisional: ExtensionAPI is recreated before the stable session owner is known. */
61
63
  export function beginExtensionLifecycle(): { environment: NodeJS.ProcessEnv; lifecycle: ReloadLifecycle } {
62
- return { environment: process.env, lifecycle: { root: snapshot() } };
64
+ const root = snapshot();
65
+ const publication = state().latestChildPublication;
66
+ if (
67
+ publication?.environment[ENV_AUTO_MODE_REF] &&
68
+ root[ENV_AUTO_MODE_REF] === publication.environment[ENV_AUTO_MODE_REF]
69
+ ) {
70
+ // Recognize our own published Auto reference independently of unrelated grant setting changes.
71
+ // A new SDK owner must not adopt another local owner; a real descendant has no local publication.
72
+ if (root[ENV_AUTO_MODE] === publication.environment[ENV_AUTO_MODE])
73
+ root[ENV_AUTO_MODE] = publication.lifecycle.root[ENV_AUTO_MODE];
74
+ root[ENV_AUTO_MODE_REF] = publication.lifecycle.root[ENV_AUTO_MODE_REF];
75
+ }
76
+ return { environment: withRoot(root), lifecycle: { root } };
63
77
  }
64
78
 
65
79
  /**
@@ -100,6 +114,7 @@ export function bindReloadLifecycle(
100
114
  // Pending old-root operations keep THEIR original owner; they cannot publish/fault this replacement.
101
115
  existing = {
102
116
  root: current,
117
+ autoMode: existing.autoMode,
103
118
  activityRootId: existing.activityRootId,
104
119
  episodeId: existing.episodeId,
105
120
  capacity: existing.capacity,
@@ -79,6 +79,13 @@ export interface GatedPlan {
79
79
  approval?: ApprovalOutcome;
80
80
  }
81
81
 
82
+ /** Discard provisional Auto facts when OFF or a policy error requires a fresh decision. */
83
+ function forgetAutoApprovals(outcome: ApprovalOutcome, capabilities: string[]): void {
84
+ outcome.approved = outcome.approved.filter((capability) => !capabilities.includes(capability));
85
+ for (const field of ["sources", "scopes", "recordedScopes", "bindings", "expiresAt", "uses"] as const)
86
+ for (const capability of capabilities) delete outcome[field][capability];
87
+ }
88
+
82
89
  /**
83
90
  * Plan a delegation and satisfy its gate as far as approvals allow.
84
91
  *
@@ -143,12 +150,13 @@ export async function planWithApprovals(
143
150
  plan.approvalBinding,
144
151
  );
145
152
  const outcome = approval;
146
- if (outcome.approved.length > 0) {
147
- plan = observedPlan({
153
+ while (outcome.approved.length > 0) {
154
+ const context = {
148
155
  // The scope is the REAL one: a `once` approval still authorises this spawn, and
149
156
  // `inheritApprovals` then keeps it from reaching the child. See ADR-0014. R-29 is what makes
150
157
  // this safe under fan-out: a `once` is consumed by exactly one concurrent caller.
151
158
  ...(await session.delegationContext([
159
+ ...(preApproved ?? []),
152
160
  ...republishable(session),
153
161
  ...outcome.approved.map((capability) => ({
154
162
  capability,
@@ -167,7 +175,35 @@ export async function planWithApprovals(
167
175
  })),
168
176
  ])),
169
177
  ...extra,
170
- });
178
+ };
179
+ const automatic = outcome.approved.filter((capability) => outcome.sources[capability] === "auto");
180
+ // The owner serializes this check with OFF. Permission is admitted here, after context preparation;
181
+ // a later OFF does not cancel a delegation already admitted to its load-bearing ledger write.
182
+ if (automatic.length > 0 && !(await session.autoMode?.admit(signal))) {
183
+ const renewed = await obtainApprovals(
184
+ session,
185
+ automatic,
186
+ approvalSubject,
187
+ request.agent ? "definition" : "delegate",
188
+ ctx,
189
+ request.task,
190
+ signal,
191
+ plan.approvalBinding,
192
+ );
193
+ forgetAutoApprovals(outcome, automatic);
194
+ outcome.approved.push(...renewed.approved);
195
+ for (const field of ["sources", "scopes", "recordedScopes", "bindings", "expiresAt", "uses"] as const)
196
+ Object.assign(outcome[field], renewed[field]);
197
+ outcome.banked = [...(outcome.banked ?? []), ...(renewed.banked ?? [])];
198
+ outcome.humanDenied ||= renewed.humanDenied;
199
+ outcome.gateOutcome = renewed.gateOutcome;
200
+ outcome.refusalCode = renewed.refusalCode;
201
+ outcome.reason = renewed.reason;
202
+ if (automatic.some((capability) => !renewed.approved.includes(capability))) break;
203
+ continue;
204
+ }
205
+ plan = observedPlan(context);
206
+ break;
171
207
  }
172
208
  if (!plan.ok && approval.reason) {
173
209
  plan = {
@@ -178,6 +214,16 @@ export async function planWithApprovals(
178
214
  }
179
215
  } catch (error) {
180
216
  const message = `grants: approval flow failed, denying (${String(error)})`;
217
+ if (approval) {
218
+ const failed = approval;
219
+ forgetAutoApprovals(
220
+ failed,
221
+ failed.approved.filter((capability) => failed.sources[capability] === "auto"),
222
+ );
223
+ approval.gateOutcome = "error";
224
+ approval.refusalCode = "APPROVAL_FLOW_FAILED";
225
+ approval.reason = message;
226
+ }
181
227
  plan = { ...plan, reason: message, refusal: structuredRefusal("APPROVAL_FLOW_FAILED", message) };
182
228
  }
183
229
 
@@ -0,0 +1,132 @@
1
+ /** Session-owned Auto policy. Dashboard mutations never become banked or inherited approvals. */
2
+ import type { AutoModeReader, AutoModeSource } from "../src/kernel/auto-mode.ts";
3
+ import { parseAutoModeDefault, parseAutoModeRef } from "../src/kernel/auto-mode.ts";
4
+ import { ENV_AUTO_MODE, ENV_AUTO_MODE_REF } from "../src/kernel/env-names.ts";
5
+ import {
6
+ createAutoModeAuthority,
7
+ connectAutoMode,
8
+ type AutoModeAuthority,
9
+ } from "../src/governance/auto-mode-policy.ts";
10
+ import { appendLedgerEvent, buildAutoModeConfigEvent } from "../src/governance/ledger.ts";
11
+ import type { GrantsSession } from "./session.ts";
12
+
13
+ export interface SessionAutoLifecycle {
14
+ nativeSessionId: string;
15
+ reader: AutoModeReader;
16
+ authority?: AutoModeAuthority;
17
+ updates: Promise<unknown>;
18
+ }
19
+ const stale = () => new Error("Daddy Auto controls are unavailable for this session owner");
20
+ function current(session: GrantsSession): SessionAutoLifecycle {
21
+ const state = session.reloadLifecycle.autoMode;
22
+ if (!session.ownerBound || !state || session.autoMode !== state.reader) throw stale();
23
+ return state;
24
+ }
25
+ async function record(
26
+ session: GrantsSession,
27
+ enabled: boolean,
28
+ source: AutoModeSource,
29
+ revision: number,
30
+ ): Promise<void> {
31
+ if (!session.ledgerPath) return;
32
+ await appendLedgerEvent(
33
+ { path: session.ledgerPath, strict: true },
34
+ buildAutoModeConfigEvent({
35
+ episodeId: session.episodeId,
36
+ enabled,
37
+ source,
38
+ revision,
39
+ now: new Date(),
40
+ }),
41
+ );
42
+ }
43
+
44
+ export async function initializeSessionAutoMode(
45
+ session: GrantsSession,
46
+ environment: NodeJS.ProcessEnv,
47
+ nativeSessionId: string,
48
+ ): Promise<void> {
49
+ const lifecycle = session.reloadLifecycle;
50
+ let state = lifecycle.autoMode;
51
+ if (session.autoMode && session.autoMode !== state?.reader) await session.autoMode.close();
52
+ if (state && state.nativeSessionId !== nativeSessionId) {
53
+ await state.reader.close();
54
+ delete lifecycle.autoMode;
55
+ state = undefined;
56
+ }
57
+ if (state) {
58
+ session.autoMode = state.reader;
59
+ return;
60
+ }
61
+ const rawRef = environment[ENV_AUTO_MODE_REF];
62
+ if (rawRef !== undefined) {
63
+ // A descendant always consults its actual owner. A dead/malformed owner must not mint a new root.
64
+ const reader = connectAutoMode(parseAutoModeRef(rawRef));
65
+ try {
66
+ await reader.read();
67
+ } catch (error) {
68
+ await reader.close();
69
+ throw error;
70
+ }
71
+ state = { nativeSessionId, reader, updates: Promise.resolve() };
72
+ } else {
73
+ const initial = parseAutoModeDefault(environment[ENV_AUTO_MODE]);
74
+ const authority = await createAutoModeAuthority({ enabled: false, source: initial.source });
75
+ try {
76
+ if (initial.enabled) {
77
+ await record(session, true, initial.source, 1);
78
+ authority.set(true, initial.source);
79
+ }
80
+ } catch (error) {
81
+ await authority.close();
82
+ throw error;
83
+ }
84
+ state = { nativeSessionId, reader: authority, authority, updates: Promise.resolve() };
85
+ }
86
+ if (session.reloadLifecycle !== lifecycle) {
87
+ await state.reader.close();
88
+ throw stale();
89
+ }
90
+ lifecycle.autoMode = state;
91
+ session.autoMode = state.reader;
92
+ delete session.autoModeFailure;
93
+ }
94
+
95
+ export async function getDashboardControlState(session: GrantsSession) {
96
+ const state = current(session);
97
+ const value = await state.reader.read();
98
+ if (current(session) !== state) throw stale();
99
+ return { auto: { enabled: value.enabled, source: value.source }, pendingApprovals: value.pendingApprovals };
100
+ }
101
+
102
+ export async function setAutoApproval(session: GrantsSession, enabled: boolean, source: "dashboard" | "command") {
103
+ if (typeof enabled !== "boolean" || (source !== "dashboard" && source !== "command"))
104
+ throw new Error("Invalid Daddy Auto mode edit");
105
+ const state = current(session);
106
+ if (!state.authority) throw new Error("Only the owning root session may change Daddy Auto mode");
107
+ // Serialize operator edits. OFF remains effective even if its audit write fails; failed ON never enables.
108
+ const update = state.updates
109
+ .catch(() => {})
110
+ .then(async () => {
111
+ if (current(session) !== state) throw stale();
112
+ const before = state.authority!.snapshot();
113
+ if (enabled) {
114
+ await record(session, true, "session", before.revision + 1);
115
+ if (current(session) !== state) throw stale();
116
+ state.authority!.set(true, "session");
117
+ } else {
118
+ const after = state.authority!.set(false, "session");
119
+ await record(session, false, "session", after.revision);
120
+ }
121
+ return getDashboardControlState(session);
122
+ });
123
+ state.updates = update;
124
+ return update;
125
+ }
126
+
127
+ export async function closeSessionAutoMode(session: GrantsSession): Promise<void> {
128
+ const reader = session.autoMode;
129
+ delete session.autoMode;
130
+ if (reader && session.reloadLifecycle.autoMode?.reader === reader) delete session.reloadLifecycle.autoMode;
131
+ await reader?.close();
132
+ }
@@ -1,3 +1,5 @@
1
+ import type { EcosystemVersions } from "../src/products/ecosystem-versions.ts";
2
+ import type { AutoModeReader } from "../src/kernel/auto-mode.ts";
1
3
  /**
2
4
  * The session object — the one place this extension's state is named.
3
5
  *
@@ -141,6 +143,9 @@ export function activityChildEnv(activity: { rootId: string; path: string; taskI
141
143
  }
142
144
  /** Keep each child's pane after it finishes, for inspection. Off by default: fan-out would flood it. */
143
145
  export interface GrantsSession extends NativeSessionHost {
146
+ ecosystemVersions?: () => EcosystemVersions;
147
+ autoMode?: AutoModeReader;
148
+ autoModeFailure?: string;
144
149
  /** Reconciled from the actual owner root on reload; captured call owners stay immutable. */
145
150
  publicEvidence?: PublicEvidenceOwner;
146
151
  /** Legacy PI_GRANTS_* names adopted at construction (ADR-0076 PR 3b); the session-start warning names them. */
@@ -659,43 +664,47 @@ export function createGrantsSession(
659
664
  discovery: Object.freeze({ ...discovery }),
660
665
  catalog: emptyCatalog,
661
666
  catalogReady: Promise.resolve(emptyCatalog),
662
- delegationContext: async (approved?: InheritableApproval[]) => ({
663
- ownGrant: session.ownGrant,
664
- episodeId: session.episodeId,
665
- depth: session.depth,
666
- maxDepth: session.maxDepth,
667
- gated: session.gated,
668
- ledgerPath: session.ledgerPath,
669
- extensionPath: session.extensionPath,
670
- observerExtensionPath: session.observerExtensionPath,
671
- childEnv: activityChildEnv(session.activity),
672
- // ADR-0042: the pin this session holds, handed to the kernel so a delegated child inherits a narrowed
673
- // one through the same builder `publishChildEnv` uses. Read here rather than in the kernel so there is
674
- // one place that knows the environment is where a session's own pin lives.
675
- ...(session.workspacePin ? { workspacePin: session.workspacePin } : {}),
676
- // Context is staged only after its capability survives the ceiling, grant and gate.
677
- stageHandoff: (granted, options) =>
678
- createHandoffStager({
679
- cwd: session.cwd,
680
- forkRoot: join(agentDir(), "context-forks"),
681
- ...(session.parentSession ? { parentSession: session.parentSession } : {}),
682
- })(granted, options),
683
- catalog: await delegationCatalog(session),
684
- // R-32: where each granted skill lives, so `planSpawn` can pass `--skill` for those and only those.
685
- // Derived from the catalog's own `source`, so it cannot drift from what was discovered.
686
- skillPaths: skillPathsFromCatalog(await delegationCatalog(session)),
687
- // ADR-0016: operator-authored SKILL.md definitions, so `delegate({agent})` can name one.
688
- definitions: session.definitions,
689
- // The herdr executor drives the child after starting it, so its plan must NOT carry `--print`.
690
- // Threaded through the plan rather than patched afterwards: the argv is what the ledger records, and
691
- // an executor quietly rewriting it would make the record describe a spawn that did not happen.
692
- //
693
- // Read live off `session.executor` (ADR-0031) rather than a boolean captured in the factory: the probe
694
- // has not run when this session object is built, so a captured value would plan `--print` for a session
695
- // that turns out to use panes — and `runHerdrPane` refuses a plan containing `--print` by design.
696
- interactive: false, // Both qualified backends use the same one-shot Pi JSON protocol.
697
- ...(approved ? { approved } : {}),
698
- }),
667
+ delegationContext: async (approved?: InheritableApproval[]) => {
668
+ if (session.autoModeFailure) throw new Error(session.autoModeFailure);
669
+ return {
670
+ autoModeRef: session.autoMode?.reference,
671
+ ownGrant: session.ownGrant,
672
+ episodeId: session.episodeId,
673
+ depth: session.depth,
674
+ maxDepth: session.maxDepth,
675
+ gated: session.gated,
676
+ ledgerPath: session.ledgerPath,
677
+ extensionPath: session.extensionPath,
678
+ observerExtensionPath: session.observerExtensionPath,
679
+ childEnv: activityChildEnv(session.activity),
680
+ // ADR-0042: the pin this session holds, handed to the kernel so a delegated child inherits a narrowed
681
+ // one through the same builder `publishChildEnv` uses. Read here rather than in the kernel so there is
682
+ // one place that knows the environment is where a session's own pin lives.
683
+ ...(session.workspacePin ? { workspacePin: session.workspacePin } : {}),
684
+ // Context is staged only after its capability survives the ceiling, grant and gate.
685
+ stageHandoff: (granted, options) =>
686
+ createHandoffStager({
687
+ cwd: session.cwd,
688
+ forkRoot: join(agentDir(), "context-forks"),
689
+ ...(session.parentSession ? { parentSession: session.parentSession } : {}),
690
+ })(granted, options),
691
+ catalog: await delegationCatalog(session),
692
+ // R-32: where each granted skill lives, so `planSpawn` can pass `--skill` for those and only those.
693
+ // Derived from the catalog's own `source`, so it cannot drift from what was discovered.
694
+ skillPaths: skillPathsFromCatalog(await delegationCatalog(session)),
695
+ // ADR-0016: operator-authored SKILL.md definitions, so `delegate({agent})` can name one.
696
+ definitions: session.definitions,
697
+ // The herdr executor drives the child after starting it, so its plan must NOT carry `--print`.
698
+ // Threaded through the plan rather than patched afterwards: the argv is what the ledger records, and
699
+ // an executor quietly rewriting it would make the record describe a spawn that did not happen.
700
+ //
701
+ // Read live off `session.executor` (ADR-0031) rather than a boolean captured in the factory: the probe
702
+ // has not run when this session object is built, so a captured value would plan `--print` for a session
703
+ // that turns out to use panes — and `runHerdrPane` refuses a plan containing `--print` by design.
704
+ interactive: false, // Both qualified backends use the same one-shot Pi JSON protocol.
705
+ ...(approved ? { approved } : {}),
706
+ };
707
+ },
699
708
  storeCwd,
700
709
  ...(grantStoreRefusal ? { grantStoreRefusal } : {}),
701
710
  adoptGrant: (grant: Capability[], projectLedger?: string) => {
@@ -725,6 +734,7 @@ export function createGrantsSession(
725
734
  },
726
735
  publishChildEnv: () => {
727
736
  const env = childEnv({
737
+ autoModeRef: session.autoMode?.reference,
728
738
  ownGrant: session.ownGrant,
729
739
  episodeId: session.episodeId,
730
740
  depth: session.depth,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-daddy",
3
- "version": "0.46.1",
3
+ "version": "0.47.0",
4
4
  "description": "Capability governance for pi sub-agents: spawn Agent Skills (SKILL.md) definitions whose allowed-tools becomes a grant that can only narrow going down a delegation tree, enforced by pi's own --tools allowlist, with an append-only ledger.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -53,7 +53,7 @@
53
53
  "typecheck": "tsc -p tsconfig.check.json",
54
54
  "test": "node scripts/unit-tests.ts",
55
55
  "test:integration": "node --test test-integration/*.it.ts",
56
- "test:integration:ci": "node --test test-integration/approval.it.ts test-integration/governance.it.ts test-integration/activity.it.ts",
56
+ "test:integration:ci": "node --test test-integration/approval.it.ts test-integration/governance.it.ts test-integration/activity.it.ts test-integration/auto-chain.it.ts",
57
57
  "contracts:generate": "node scripts/generate-ledger-record-contract.ts",
58
58
  "prepack": "npm run build",
59
59
  "test:smoke": "node scripts/smoke-installed.mjs",
@@ -10,6 +10,8 @@
10
10
  * no-op UI context whose `select` resolves undefined, so even a missed `hasUI` check would deny.
11
11
  */
12
12
 
13
+ import { randomUUID } from "node:crypto";
14
+ import type { AutoModeReader } from "../kernel/auto-mode.ts";
13
15
  import { approvalKey, offeredScopes, type ApprovalPath, type ApprovalScope } from "../kernel/approval.ts";
14
16
  import type { Capability } from "../kernel/resolve.ts";
15
17
 
@@ -72,6 +74,8 @@ export interface PromptOutcome {
72
74
  * one. The rider's honest source is `session`, and this flag is how the caller can tell.
73
75
  */
74
76
  joined?: boolean;
77
+ /** Provisional Auto permission; the admission caller must still check the live owner policy. */
78
+ source?: "auto";
75
79
  }
76
80
 
77
81
  export interface ApprovalGateOptions {
@@ -82,6 +86,7 @@ export interface ApprovalGateOptions {
82
86
  mode: string;
83
87
  /** Resolve only when this caller must open a new dialog; unused invalid settings cannot revoke authority. */
84
88
  timeoutMs?: number | (() => number | undefined);
89
+ autoMode?: AutoModeReader;
85
90
  }
86
91
 
87
92
  export interface ApprovalGate {
@@ -106,6 +111,8 @@ function labelToScope(label: string, scopes: ApprovalScope[]): ApprovalScope | n
106
111
  return scopes.find((s) => SCOPE_LABELS[s] === label) ?? null;
107
112
  }
108
113
 
114
+ const autoApproval = (): PromptOutcome => ({ scope: "once", kind: "granted", source: "auto" });
115
+
109
116
  const interrupted = (request: PromptRequest, kind: "expired" | "aborted"): PromptOutcome => ({
110
117
  scope: null,
111
118
  kind,
@@ -124,26 +131,61 @@ async function selectApproval(
124
131
  const timeout = typeof options.timeoutMs === "function" ? options.timeoutMs() : options.timeoutMs;
125
132
  const controller = new AbortController();
126
133
  let outcome: PromptOutcome | undefined;
134
+ let settled = false;
127
135
  let wake!: (value: PromptOutcome) => void;
128
136
  const stopped = new Promise<PromptOutcome>((resolve) => {
129
137
  wake = resolve;
130
138
  });
131
- const stop = (kind: "expired" | "aborted") => {
132
- if (outcome) return;
133
- outcome = interrupted(request, kind);
139
+ const settle = (next: PromptOutcome) => {
140
+ if (settled) return;
141
+ settled = true;
142
+ outcome = next;
134
143
  // Resolve our tagged outcome before a UI reacts to abort by returning undefined.
135
144
  wake(outcome);
136
145
  controller.abort();
137
146
  };
147
+ const stop = (kind: "expired" | "aborted") => settle(interrupted(request, kind));
138
148
  const abort = () => stop("aborted");
139
149
  request.signal?.addEventListener("abort", abort, { once: true });
140
150
  const timer = timeout === undefined ? undefined : setTimeout(() => stop("expired"), timeout);
151
+ let untrack: (() => void) | undefined;
141
152
  try {
142
153
  if (request.signal?.aborted) stop("aborted");
143
154
  if (outcome) return outcome;
144
- const chosen = await Promise.race([options.ui.select(title, labels, { signal: controller.signal }), stopped]);
155
+ untrack = options.autoMode?.trackPending({
156
+ id: randomUUID(),
157
+ subject: request.subject,
158
+ capability: request.capability,
159
+ });
160
+ const selection = options.ui.select(title, labels, { signal: controller.signal }).then((chosen) => {
161
+ settled = true;
162
+ return chosen;
163
+ });
164
+ if (options.autoMode) {
165
+ const auto = options.autoMode;
166
+ void (async () => {
167
+ while (!controller.signal.aborted) {
168
+ await auto.waitEnabled(controller.signal);
169
+ // A notification is not authority: ON may already have changed back to OFF.
170
+ if (!controller.signal.aborted && (await auto.read()).enabled) {
171
+ settle(autoApproval());
172
+ return;
173
+ }
174
+ }
175
+ })().catch((error) => {
176
+ if (!controller.signal.aborted)
177
+ settle({
178
+ scope: null,
179
+ kind: "error",
180
+ reason: `Auto permission policy unavailable, denying (${String(error)})`,
181
+ });
182
+ });
183
+ }
184
+ const chosen = await Promise.race([selection, stopped]);
145
185
  return outcome ?? chosen;
146
186
  } finally {
187
+ controller.abort();
188
+ untrack?.();
147
189
  if (timer !== undefined) clearTimeout(timer);
148
190
  request.signal?.removeEventListener("abort", abort);
149
191
  }
@@ -211,6 +253,15 @@ export function createApprovalGate(
211
253
  inFlight: InFlightApprovals = new Map(),
212
254
  ): ApprovalGate {
213
255
  const ask = async (request: PromptRequest): Promise<PromptOutcome> => {
256
+ // A queued once waiter owns its own decision. Auto may have changed while it joined another dialog.
257
+ if (options.autoMode) {
258
+ try {
259
+ if ((await options.autoMode.read()).enabled)
260
+ return request.signal?.aborted ? interrupted(request, "aborted") : autoApproval();
261
+ } catch (error) {
262
+ return { scope: null, kind: "error", reason: `Auto permission policy unavailable, denying (${String(error)})` };
263
+ }
264
+ }
214
265
  const scopes = offeredScopes(request.path);
215
266
  const title =
216
267
  `grants: approve ${request.capability} for ${request.subject}?` +
@@ -251,6 +302,13 @@ export function createApprovalGate(
251
302
  return {
252
303
  async request(request: PromptRequest): Promise<PromptOutcome> {
253
304
  if (request.signal?.aborted) return interrupted(request, "aborted");
305
+ try {
306
+ if (options.autoMode && (await options.autoMode.read()).enabled) {
307
+ return request.signal?.aborted ? interrupted(request, "aborted") : autoApproval();
308
+ }
309
+ } catch (error) {
310
+ return { scope: null, kind: "error", reason: `Auto permission policy unavailable, denying (${String(error)})` };
311
+ }
254
312
  if (!options.hasUI) {
255
313
  // Nobody was there to ask — distinct from a person declining, which is why this is its own kind
256
314
  // rather than being folded into "declined" (see PromptOutcomeKind).