pi-daddy 0.46.0 → 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 (108) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +77 -11
  3. package/contracts/ledger-record/v1/governance-event.schema.json +67 -3
  4. package/dist/governance/approval-prompt.d.ts +8 -11
  5. package/dist/governance/approval-prompt.d.ts.map +1 -1
  6. package/dist/governance/approval-prompt.js +138 -24
  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 +18 -4
  25. package/dist/governance/ledger-v3-validation.js.map +1 -1
  26. package/dist/governance/ledger.d.ts +4 -5
  27. package/dist/governance/ledger.d.ts.map +1 -1
  28. package/dist/governance/ledger.js +2 -2
  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/activity-timeline.ts +94 -31
  77. package/extensions/approvals.ts +3 -2
  78. package/extensions/dashboard-session-server.ts +32 -4
  79. package/extensions/delegate-chain.ts +2 -0
  80. package/extensions/delegation-ledger.ts +10 -6
  81. package/extensions/ecosystem-versions.ts +166 -0
  82. package/extensions/execution-controls.ts +42 -0
  83. package/extensions/grants-command.ts +37 -3
  84. package/extensions/grants.ts +29 -2
  85. package/extensions/reload-environment.ts +16 -1
  86. package/extensions/run-delegation.ts +49 -3
  87. package/extensions/session-auto-mode.ts +132 -0
  88. package/extensions/session.ts +47 -37
  89. package/package.json +2 -2
  90. package/src/governance/approval-prompt.ts +144 -29
  91. package/src/governance/auto-mode-policy.ts +297 -0
  92. package/src/governance/execution-retention.ts +24 -0
  93. package/src/governance/ledger-events.ts +22 -1
  94. package/src/governance/ledger-report.ts +5 -3
  95. package/src/governance/ledger-v3-validation.ts +18 -4
  96. package/src/governance/ledger.ts +4 -4
  97. package/src/kernel/approval.ts +1 -1
  98. package/src/kernel/auto-mode.ts +56 -0
  99. package/src/kernel/delegate-types.ts +2 -0
  100. package/src/kernel/delegate.ts +2 -1
  101. package/src/kernel/env-names.ts +4 -0
  102. package/src/kernel/propagation.ts +11 -2
  103. package/src/products/dashboard-cli.ts +255 -53
  104. package/src/products/dashboard-display-controls.ts +52 -0
  105. package/src/products/dashboard-render.ts +2 -2
  106. package/src/products/dashboard-screen.ts +339 -0
  107. package/src/products/dashboard-session-client.ts +60 -1
  108. package/src/products/ecosystem-versions.ts +18 -0
@@ -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.0",
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
 
@@ -43,21 +45,22 @@ export interface PromptRequest {
43
45
  signal?: AbortSignal;
44
46
  }
45
47
 
46
- /**
47
- * Which of the five outcomes a request produced.
48
- *
49
- * `scope: null` alone conflates four different kinds of no — a caller that needs to know whether a
50
- * PERSON actually declined (as opposed to nobody being there to ask, a dismissal, or the dialog itself
51
- * breaking) cannot recover that from `scope` or from parsing `reason`. Only "declined" means a human said
52
- * no; the other three are absence-of-signal, not a signal.
53
- */
54
- export const PROMPT_OUTCOME_KINDS = ["granted", "declined", "dismissed", "no-ui", "error"] as const;
48
+ /** Only declined means a human chose Deny. Expiry, cancellation and dismissal grant nothing. */
49
+ export const PROMPT_OUTCOME_KINDS = [
50
+ "granted",
51
+ "declined",
52
+ "dismissed",
53
+ "expired",
54
+ "aborted",
55
+ "no-ui",
56
+ "error",
57
+ ] as const;
55
58
  export type PromptOutcomeKind = (typeof PROMPT_OUTCOME_KINDS)[number];
56
59
 
57
60
  export interface PromptOutcome {
58
61
  /** The scope the human chose, or null for any form of no. */
59
62
  scope: ApprovalScope | null;
60
- /** Which of the five outcomes this was. Set on every return path. */
63
+ /** Which outcome this was. Set on every return path. */
61
64
  kind: PromptOutcomeKind;
62
65
  /** Why, when the answer was no. */
63
66
  reason?: string;
@@ -71,6 +74,8 @@ export interface PromptOutcome {
71
74
  * one. The rider's honest source is `session`, and this flag is how the caller can tell.
72
75
  */
73
76
  joined?: boolean;
77
+ /** Provisional Auto permission; the admission caller must still check the live owner policy. */
78
+ source?: "auto";
74
79
  }
75
80
 
76
81
  export interface ApprovalGateOptions {
@@ -81,23 +86,21 @@ export interface ApprovalGateOptions {
81
86
  mode: string;
82
87
  /** Resolve only when this caller must open a new dialog; unused invalid settings cannot revoke authority. */
83
88
  timeoutMs?: number | (() => number | undefined);
89
+ autoMode?: AutoModeReader;
84
90
  }
85
91
 
86
92
  export interface ApprovalGate {
87
93
  request(request: PromptRequest): Promise<PromptOutcome>;
88
94
  }
89
95
 
90
- const DEFAULT_TIMEOUT_MS = 120_000;
91
-
92
96
  /**
93
97
  * Read `PI_DADDY_APPROVAL_TIMEOUT`, in SECONDS, into the milliseconds pi expects.
94
98
  *
95
- * Only literal `0` disables the deadline. Other values must be canonical whole seconds
99
+ * Absent or literal `0` waits for the human without a deadline. Other values must be canonical whole seconds
96
100
  * within Node's signed 32-bit millisecond timer limit; malformed settings refuse a needed prompt.
97
101
  */
98
102
  export function timeoutMsFromEnv(raw: string | undefined): number | undefined {
99
- if (raw === undefined) return DEFAULT_TIMEOUT_MS;
100
- if (raw === "0") return undefined;
103
+ if (raw === undefined || raw === "0") return undefined;
101
104
  if (!/^[1-9][0-9]*$/.test(raw) || Number(raw) > 2_147_483)
102
105
  throw new Error(
103
106
  "PI_DADDY_APPROVAL_TIMEOUT must be literal 0 or whole seconds 1..2147483 without signs, padding or units",
@@ -108,6 +111,101 @@ function labelToScope(label: string, scopes: ApprovalScope[]): ApprovalScope | n
108
111
  return scopes.find((s) => SCOPE_LABELS[s] === label) ?? null;
109
112
  }
110
113
 
114
+ const autoApproval = (): PromptOutcome => ({ scope: "once", kind: "granted", source: "auto" });
115
+
116
+ const interrupted = (request: PromptRequest, kind: "expired" | "aborted"): PromptOutcome => ({
117
+ scope: null,
118
+ kind,
119
+ reason:
120
+ `approval for ${request.capability} ${kind === "expired" ? "expired before an answer" : "was canceled"}. ` +
121
+ "No permission was granted. Retry the delegation to open a new approval prompt.",
122
+ });
123
+
124
+ /** Own the deadline so Pi's undefined dismissal cannot erase whether our timer or cancellation fired. */
125
+ async function selectApproval(
126
+ options: ApprovalGateOptions,
127
+ request: PromptRequest,
128
+ title: string,
129
+ labels: string[],
130
+ ): Promise<string | undefined | PromptOutcome> {
131
+ const timeout = typeof options.timeoutMs === "function" ? options.timeoutMs() : options.timeoutMs;
132
+ const controller = new AbortController();
133
+ let outcome: PromptOutcome | undefined;
134
+ let settled = false;
135
+ let wake!: (value: PromptOutcome) => void;
136
+ const stopped = new Promise<PromptOutcome>((resolve) => {
137
+ wake = resolve;
138
+ });
139
+ const settle = (next: PromptOutcome) => {
140
+ if (settled) return;
141
+ settled = true;
142
+ outcome = next;
143
+ // Resolve our tagged outcome before a UI reacts to abort by returning undefined.
144
+ wake(outcome);
145
+ controller.abort();
146
+ };
147
+ const stop = (kind: "expired" | "aborted") => settle(interrupted(request, kind));
148
+ const abort = () => stop("aborted");
149
+ request.signal?.addEventListener("abort", abort, { once: true });
150
+ const timer = timeout === undefined ? undefined : setTimeout(() => stop("expired"), timeout);
151
+ let untrack: (() => void) | undefined;
152
+ try {
153
+ if (request.signal?.aborted) stop("aborted");
154
+ if (outcome) return outcome;
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]);
185
+ return outcome ?? chosen;
186
+ } finally {
187
+ controller.abort();
188
+ untrack?.();
189
+ if (timer !== undefined) clearTimeout(timer);
190
+ request.signal?.removeEventListener("abort", abort);
191
+ }
192
+ }
193
+
194
+ async function joinApproval(pending: Promise<PromptOutcome>, request: PromptRequest): Promise<PromptOutcome> {
195
+ if (!request.signal) return pending;
196
+ let abort!: () => void;
197
+ const stopped = new Promise<PromptOutcome>((resolve) => {
198
+ abort = () => resolve(interrupted(request, "aborted"));
199
+ request.signal!.addEventListener("abort", abort, { once: true });
200
+ if (request.signal!.aborted) abort();
201
+ });
202
+ try {
203
+ return await Promise.race([pending, stopped]);
204
+ } finally {
205
+ request.signal.removeEventListener("abort", abort);
206
+ }
207
+ }
208
+
111
209
  /**
112
210
  * The single-flight queue: one dialog per approval key at a time.
113
211
  *
@@ -155,29 +253,41 @@ export function createApprovalGate(
155
253
  inFlight: InFlightApprovals = new Map(),
156
254
  ): ApprovalGate {
157
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
+ }
158
265
  const scopes = offeredScopes(request.path);
159
266
  const title =
160
267
  `grants: approve ${request.capability} for ${request.subject}?` +
161
268
  (request.task ? `\n task: ${request.task}` : "");
162
269
 
163
- let chosen: string | undefined;
270
+ let chosen: string | undefined | PromptOutcome;
164
271
  try {
165
- chosen = await options.ui.select(title, [DENY_LABEL, ...scopes.map((s) => SCOPE_LABELS[s])], {
166
- timeout: typeof options.timeoutMs === "function" ? options.timeoutMs() : options.timeoutMs,
167
- signal: request.signal,
168
- });
272
+ chosen = await selectApproval(options, request, title, [DENY_LABEL, ...scopes.map((s) => SCOPE_LABELS[s])]);
169
273
  } catch (error) {
170
274
  // A governance layer that errors must deny, not permit. This is a dialog malfunction, not a
171
275
  // person's answer — `kind: "error"` keeps it out of "declined".
172
276
  return { scope: null, kind: "error", reason: `approval dialog failed, denying (${String(error)})` };
173
277
  }
174
278
 
175
- if (request.signal?.aborted)
176
- return { scope: null, kind: "dismissed", reason: `approval for ${request.capability} was canceled` };
279
+ if (request.signal?.aborted) return interrupted(request, "aborted");
177
280
 
178
- // undefined covers dismissal, timeout, and abort. All are a no, but none is a person saying no.
281
+ if (typeof chosen === "object") return chosen;
282
+ // Only an ordinary UI dismissal reaches undefined; our deadline and abort carry explicit outcomes.
179
283
  if (chosen === undefined) {
180
- return { scope: null, kind: "dismissed", reason: `approval for ${request.capability} was dismissed` };
284
+ return {
285
+ scope: null,
286
+ kind: "dismissed",
287
+ reason:
288
+ `approval for ${request.capability} was dismissed. No permission was granted. ` +
289
+ "Retry the delegation to open a new approval prompt.",
290
+ };
181
291
  }
182
292
  if (chosen === DENY_LABEL) {
183
293
  return { scope: null, kind: "declined", reason: `${request.capability} was denied by a human` };
@@ -191,8 +301,14 @@ export function createApprovalGate(
191
301
 
192
302
  return {
193
303
  async request(request: PromptRequest): Promise<PromptOutcome> {
194
- if (request.signal?.aborted)
195
- return { scope: null, kind: "dismissed", reason: `approval for ${request.capability} was canceled` };
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
+ }
196
312
  if (!options.hasUI) {
197
313
  // Nobody was there to ask — distinct from a person declining, which is why this is its own kind
198
314
  // rather than being folded into "declined" (see PromptOutcomeKind).
@@ -215,9 +331,8 @@ export function createApprovalGate(
215
331
  for (;;) {
216
332
  const existing = inFlight.get(key);
217
333
  if (!existing) break;
218
- const outcome = await existing;
219
- if (request.signal?.aborted)
220
- return { scope: null, kind: "dismissed", reason: `approval for ${request.capability} was canceled` };
334
+ const outcome = await joinApproval(existing, request);
335
+ if (request.signal?.aborted) return interrupted(request, "aborted");
221
336
  // **`joined` marks the rider, and the ledger depends on it (R-66).** Sharing a non-`once` outcome
222
337
  // is correct — the human authorised the capability for the session, not for one child — but the
223
338
  // caller then stamped `approvalSource: "prompt"` on every one of them, so a fan-out of eight wrote