@gotgenes/pi-permission-system 26.0.0 → 26.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,450 @@
1
+ /**
2
+ * forwarding-liveness.ts — Is anyone draining a forwarded-permission inbox?
3
+ *
4
+ * The in-process answer already exists: a serving session marks itself in the
5
+ * process-global `ServingSessionRegistry`, and an in-process child abandons a
6
+ * target that has looked unmarked for the grace window instead of waiting out
7
+ * the full forwarding timeout (#719).
8
+ *
9
+ * A child spawned as a separate `pi` process shares no `globalThis` with its
10
+ * parent, so that mark is invisible to it and it keeps waiting the full ten
11
+ * minutes — every `ask` forwarded to a session that has already exited costs
12
+ * the whole timeout and ends in a denial nobody made (#735 scenario 1).
13
+ *
14
+ * The filesystem is the only channel those two processes share, so the serving
15
+ * session publishes a heartbeat there: one record per serving session,
16
+ * refreshed while it polls and withdrawn when it stops.
17
+ */
18
+
19
+ import { readdirSync, readFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import {
22
+ ensureDirectoryExists,
23
+ isErrnoCode,
24
+ logPermissionForwardingError,
25
+ safeDeleteFile,
26
+ writeJsonFileAtomic,
27
+ } from "#src/authority/forwarding-io";
28
+ import type { PermissionForwardingTarget } from "#src/authority/permission-forwarding";
29
+ import {
30
+ encodeSessionIdForPath,
31
+ PERMISSION_FORWARDING_POLL_INTERVAL_MS,
32
+ } from "#src/authority/permission-forwarding";
33
+ import type {
34
+ ServingAnnouncer,
35
+ ServingLookup,
36
+ } from "#src/authority/serving-registry";
37
+ import type { DebugReviewLogger } from "#src/session-logger";
38
+
39
+ /**
40
+ * How often a serving session rewrites its heartbeat — four poll ticks.
41
+ *
42
+ * Longer than the poll interval so `ForwardingManager` can announce on every
43
+ * tick without four filesystem writes a second, and short enough that a record
44
+ * deleted underneath its owner reappears well inside the grace window a
45
+ * forwarding child waits out before abandoning.
46
+ */
47
+ export const SERVING_HEARTBEAT_REFRESH_MS =
48
+ 4 * PERMISSION_FORWARDING_POLL_INTERVAL_MS;
49
+
50
+ /**
51
+ * How long a heartbeat may go unrefreshed before its writer is presumed gone —
52
+ * five refreshes.
53
+ *
54
+ * Generous because a delayed Node timer is not a dead session, and because the
55
+ * case this threshold exists for (a process that is alive but no longer
56
+ * polling) is the rare one: an exited session withdraws its record and a killed
57
+ * one is caught by the recorded pid, neither of which waits for staleness.
58
+ */
59
+ export const SERVING_HEARTBEAT_STALE_MS = 5 * SERVING_HEARTBEAT_REFRESH_MS;
60
+
61
+ /** What a serving session publishes while it drains its forwarded-permission inbox. */
62
+ export interface ServingHeartbeat {
63
+ sessionId: string;
64
+ /** The serving process, so a killed session is detectable without waiting out staleness. */
65
+ pid: number;
66
+ updatedAt: number;
67
+ }
68
+
69
+ /**
70
+ * How a session's heartbeat reads right now.
71
+ *
72
+ * Only `"alive"` means someone is draining the inbox. The other three are the
73
+ * ways a target can be unserved, kept apart because they are the diagnosis a
74
+ * stalled forward needs: `"absent"` is a session that exited (or never served,
75
+ * or runs a version that does not publish), `"dead_pid"` one that was killed,
76
+ * and `"stale"` one whose process survives but stopped polling.
77
+ */
78
+ export type HeartbeatState = "alive" | "absent" | "stale" | "dead_pid";
79
+
80
+ /**
81
+ * Read side of the heartbeat channel, consumed by a forwarding child.
82
+ *
83
+ * Separate from the announce seam because the two have no caller in common: a
84
+ * serving session only publishes, and a forwarding child only reads (ISP).
85
+ */
86
+ export interface HeartbeatReader {
87
+ read(sessionId: string): HeartbeatState;
88
+ /** Every session whose record reads as alive, for the abandonment diagnostic. */
89
+ servingIds(): readonly string[];
90
+ }
91
+
92
+ /**
93
+ * Query-side seam: is the session a forwarding target names being drained?
94
+ *
95
+ * Keyed on the target rather than a session id because the answer depends on
96
+ * how the target was resolved. An in-process child and its parent share a
97
+ * `globalThis`, so the registry answers for them; an out-of-process pair shares
98
+ * only the filesystem; and a session that owns the inbox it is forwarding to is
99
+ * not a case either channel describes.
100
+ *
101
+ * Consolidating that into one collaborator is what keeps `ParentAuthorizer`
102
+ * from holding two lookups and re-deciding which one applies — the decision has
103
+ * one home, and a third channel would not reach the poll loop.
104
+ */
105
+ export interface TargetServingLookup {
106
+ /** `true` serving, `false` not serving, `null` when the target carries no signal. */
107
+ isServing(target: PermissionForwardingTarget): boolean | null;
108
+ /** What the judge observed, for the review entry a child writes when it gives up. */
109
+ describe(target: PermissionForwardingTarget): ServingObservation;
110
+ }
111
+
112
+ /** What answered a liveness question, and what it saw. */
113
+ export interface ServingObservation {
114
+ channel: "registry" | "heartbeat" | "none";
115
+ /** The heartbeat state behind a `"heartbeat"` answer; `null` on the other channels. */
116
+ state: HeartbeatState | null;
117
+ servingIds: readonly string[];
118
+ }
119
+
120
+ /** Constructor config for {@link ForwardingLivenessJudge}. */
121
+ export interface ForwardingLivenessJudgeDeps {
122
+ /** Answers for a target the requester shares a process with. */
123
+ registry: ServingLookup;
124
+ /** Answers for a target in another process. */
125
+ heartbeats: HeartbeatReader;
126
+ }
127
+
128
+ /**
129
+ * Routes a liveness question to the channel that can answer it.
130
+ *
131
+ * The routing key is `PermissionForwardingTarget.source`, which the resolver
132
+ * already produces — so "in-process" is decided once, where the target is
133
+ * found, rather than re-derived here (#719).
134
+ */
135
+ export class ForwardingLivenessJudge implements TargetServingLookup {
136
+ constructor(private readonly deps: ForwardingLivenessJudgeDeps) {}
137
+
138
+ isServing(target: PermissionForwardingTarget): boolean | null {
139
+ switch (target.source) {
140
+ case "registry":
141
+ return this.deps.registry.isServing(target.sessionId);
142
+ case "env":
143
+ return this.deps.heartbeats.read(target.sessionId) === "alive";
144
+ case "self":
145
+ return null;
146
+ }
147
+ }
148
+
149
+ describe(target: PermissionForwardingTarget): ServingObservation {
150
+ switch (target.source) {
151
+ case "registry":
152
+ return {
153
+ channel: "registry",
154
+ state: null,
155
+ servingIds: this.deps.registry.servingIds(),
156
+ };
157
+ case "env":
158
+ return {
159
+ channel: "heartbeat",
160
+ state: this.deps.heartbeats.read(target.sessionId),
161
+ servingIds: this.deps.heartbeats.servingIds(),
162
+ };
163
+ case "self":
164
+ return { channel: "none", state: null, servingIds: [] };
165
+ }
166
+ }
167
+ }
168
+
169
+ const SERVING_HEARTBEAT_DIRECTORY_NAME = "serving";
170
+
171
+ /**
172
+ * Where serving heartbeats live: beside the `sessions/` tree, never inside it.
173
+ *
174
+ * A heartbeat under `sessions/<id>/` would make that session root permanently
175
+ * non-empty, entangling liveness with the request/response cleanup whose
176
+ * removal ordering already produced an ENOENT write loop (#398). Kept disjoint,
177
+ * that logic stays untouched and "who is serving" is a single directory read.
178
+ */
179
+ export function servingHeartbeatDir(forwardingDir: string): string {
180
+ return join(forwardingDir, SERVING_HEARTBEAT_DIRECTORY_NAME);
181
+ }
182
+
183
+ /** The heartbeat record for `sessionId`, under {@link servingHeartbeatDir}. */
184
+ export function servingHeartbeatPath(
185
+ forwardingDir: string,
186
+ sessionId: string,
187
+ ): string {
188
+ return join(
189
+ servingHeartbeatDir(forwardingDir),
190
+ `${encodeSessionIdForPath(sessionId)}.json`,
191
+ );
192
+ }
193
+
194
+ /** Constructor config for {@link ServingHeartbeatStore}. */
195
+ export interface ServingHeartbeatStoreDeps {
196
+ forwardingDir: string;
197
+ logger: DebugReviewLogger;
198
+ /** Injected so the refresh throttle and staleness are testable without sleeping. */
199
+ now?: () => number;
200
+ /** The process to record. Injected so a test can publish a pid it controls. */
201
+ pid?: number;
202
+ /** Injected so a test can decide which pids are running. */
203
+ isProcessAlive?: (pid: number) => boolean;
204
+ }
205
+
206
+ /**
207
+ * Publishes this session's serving heartbeat to the filesystem.
208
+ *
209
+ * Satisfies the same {@link ServingAnnouncer} seam as `ServingSessionRegistry`,
210
+ * so `ForwardingManager` announces to both channels through one collaborator
211
+ * and neither knows the other exists.
212
+ *
213
+ * `markServing` is idempotent by that seam's contract and internally throttled,
214
+ * so the caller may announce on every poll tick. Nothing here throws: it runs
215
+ * from a timer, and a filesystem failure must degrade to the pre-existing
216
+ * timeout rather than break the poll loop.
217
+ */
218
+ export class ServingHeartbeatStore
219
+ implements ServingAnnouncer, HeartbeatReader
220
+ {
221
+ private readonly forwardingDir: string;
222
+ private readonly logger: DebugReviewLogger;
223
+ private readonly now: () => number;
224
+ private readonly pid: number;
225
+ private readonly isProcessAlive: (pid: number) => boolean;
226
+ private published: { sessionId: string; at: number } | null = null;
227
+ private hasSweptDeadRecords = false;
228
+
229
+ constructor(deps: ServingHeartbeatStoreDeps) {
230
+ this.forwardingDir = deps.forwardingDir;
231
+ this.logger = deps.logger;
232
+ this.now = deps.now ?? Date.now;
233
+ this.pid = deps.pid ?? process.pid;
234
+ this.isProcessAlive = deps.isProcessAlive ?? isRunningProcess;
235
+ }
236
+
237
+ /** Publish (or refresh) `sessionId`'s heartbeat. Throttled; never throws. */
238
+ markServing(sessionId: string): void {
239
+ const at = this.now();
240
+ if (this.isThrottled(sessionId, at)) {
241
+ return;
242
+ }
243
+
244
+ const directory = servingHeartbeatDir(this.forwardingDir);
245
+ if (
246
+ !ensureDirectoryExists(
247
+ this.logger,
248
+ directory,
249
+ "permission forwarding serving heartbeat",
250
+ )
251
+ ) {
252
+ return;
253
+ }
254
+ this.sweepDeadRecordsOnce();
255
+
256
+ const heartbeat: ServingHeartbeat = {
257
+ sessionId,
258
+ pid: this.pid,
259
+ updatedAt: at,
260
+ };
261
+ try {
262
+ writeJsonFileAtomic(
263
+ this.logger,
264
+ servingHeartbeatPath(this.forwardingDir, sessionId),
265
+ heartbeat,
266
+ );
267
+ } catch (error) {
268
+ logPermissionForwardingError(
269
+ this.logger,
270
+ `Failed to publish the serving heartbeat for session '${sessionId}'`,
271
+ error,
272
+ );
273
+ return;
274
+ }
275
+ this.published = { sessionId, at };
276
+ }
277
+
278
+ /** Withdraw `sessionId`'s heartbeat, leaving the directory for its siblings. */
279
+ clearServing(sessionId: string): void {
280
+ if (this.published?.sessionId === sessionId) {
281
+ this.published = null;
282
+ }
283
+ safeDeleteFile(
284
+ this.logger,
285
+ servingHeartbeatPath(this.forwardingDir, sessionId),
286
+ "permission forwarding serving heartbeat",
287
+ );
288
+ }
289
+
290
+ /** How `sessionId`'s heartbeat reads right now. */
291
+ read(sessionId: string): HeartbeatState {
292
+ const record = this.readRecord(
293
+ servingHeartbeatPath(this.forwardingDir, sessionId),
294
+ );
295
+ return record === null ? "absent" : this.classify(record);
296
+ }
297
+
298
+ /** Every session whose record reads as alive. */
299
+ servingIds(): readonly string[] {
300
+ const ids: string[] = [];
301
+ for (const { record } of this.listRecords()) {
302
+ if (record !== null && this.classify(record) === "alive") {
303
+ ids.push(record.sessionId);
304
+ }
305
+ }
306
+ return ids;
307
+ }
308
+
309
+ // ── Private methods ────────────────────────────────────────────────
310
+
311
+ /**
312
+ * Delete the records of processes that are provably gone, once per session.
313
+ *
314
+ * Without this the directory grows one record per session that was killed
315
+ * rather than shut down, forever. Bounded to a single directory read at the
316
+ * first announcement, and safe under pid reuse: a wrongly swept owner
317
+ * republishes within the refresh window, which is shorter than the grace a
318
+ * forwarding child waits out.
319
+ *
320
+ * Only a dead pid is proof. A record that is merely stale belongs to a
321
+ * process that still exists, and the reader already reports it as stale
322
+ * without anyone having to remove it.
323
+ */
324
+ private sweepDeadRecordsOnce(): void {
325
+ if (this.hasSweptDeadRecords) {
326
+ return;
327
+ }
328
+ this.hasSweptDeadRecords = true;
329
+ for (const { path, record } of this.listRecords()) {
330
+ if (record !== null && this.isProcessAlive(record.pid)) {
331
+ continue;
332
+ }
333
+ safeDeleteFile(
334
+ this.logger,
335
+ path,
336
+ "abandoned permission forwarding serving heartbeat",
337
+ );
338
+ }
339
+ }
340
+
341
+ /** Every published record, paired with its path; unusable ones read as `null`. */
342
+ private listRecords(): {
343
+ path: string;
344
+ record: ServingHeartbeat | null;
345
+ }[] {
346
+ const directory = servingHeartbeatDir(this.forwardingDir);
347
+ let names: string[];
348
+ try {
349
+ names = readdirSync(directory);
350
+ } catch {
351
+ return [];
352
+ }
353
+ return names
354
+ .filter((name) => name.endsWith(".json"))
355
+ .map((name) => {
356
+ const path = join(directory, name);
357
+ return { path, record: this.readRecord(path) };
358
+ });
359
+ }
360
+
361
+ /**
362
+ * Read a record, or `null` when it is missing or unusable.
363
+ *
364
+ * Silent by design: a forwarding child calls this on every poll tick, so a
365
+ * warning per unreadable read would flood the review log at four lines a
366
+ * second. The unusability is already reported once, as the `absent` state on
367
+ * the abandonment entry.
368
+ */
369
+ private readRecord(path: string): ServingHeartbeat | null {
370
+ try {
371
+ return asServingHeartbeat(JSON.parse(readFileSync(path, "utf-8")));
372
+ } catch {
373
+ return null;
374
+ }
375
+ }
376
+
377
+ /** Which of the four states a well-formed record is in. */
378
+ private classify(record: ServingHeartbeat): HeartbeatState {
379
+ if (!this.isProcessAlive(record.pid)) {
380
+ return "dead_pid";
381
+ }
382
+ return this.now() - record.updatedAt >= SERVING_HEARTBEAT_STALE_MS
383
+ ? "stale"
384
+ : "alive";
385
+ }
386
+
387
+ /**
388
+ * Whether the record on disk is recent enough to leave alone.
389
+ *
390
+ * Time alone, with no existence probe: an existence check would cost a
391
+ * syscall on every poll tick to save at most one refresh window, and a record
392
+ * removed underneath its owner reappears inside the grace window anyway.
393
+ */
394
+ private isThrottled(sessionId: string, at: number): boolean {
395
+ return (
396
+ this.published !== null &&
397
+ this.published.sessionId === sessionId &&
398
+ at - this.published.at < SERVING_HEARTBEAT_REFRESH_MS
399
+ );
400
+ }
401
+ }
402
+
403
+ // ── Module-private helpers ────────────────────────────────────────────────
404
+
405
+ /**
406
+ * Narrow a parsed record, or `undefined`.
407
+ *
408
+ * `pid` must be a positive integer specifically: `process.kill(0, 0)` addresses
409
+ * the caller's own process group and `kill(-n)` a foreign one, so a malformed
410
+ * record must be rejected before it can reach the liveness probe.
411
+ */
412
+ function asServingHeartbeat(value: unknown): ServingHeartbeat | null {
413
+ if (typeof value !== "object" || value === null) {
414
+ return null;
415
+ }
416
+ const candidate = value as Partial<ServingHeartbeat>;
417
+ if (
418
+ typeof candidate.sessionId !== "string" ||
419
+ candidate.sessionId.length === 0 ||
420
+ typeof candidate.pid !== "number" ||
421
+ !Number.isInteger(candidate.pid) ||
422
+ candidate.pid <= 0 ||
423
+ typeof candidate.updatedAt !== "number" ||
424
+ !Number.isFinite(candidate.updatedAt)
425
+ ) {
426
+ return null;
427
+ }
428
+ return {
429
+ sessionId: candidate.sessionId,
430
+ pid: candidate.pid,
431
+ updatedAt: candidate.updatedAt,
432
+ };
433
+ }
434
+
435
+ /**
436
+ * Whether `pid` names a running process.
437
+ *
438
+ * Signal `0` performs the permission and existence checks without delivering
439
+ * anything. `EPERM` means the process exists under another user — reported as
440
+ * alive, the direction that falls back to the timeout rather than abandoning a
441
+ * request someone may still answer.
442
+ */
443
+ function isRunningProcess(pid: number): boolean {
444
+ try {
445
+ process.kill(pid, 0);
446
+ return true;
447
+ } catch (error) {
448
+ return isErrnoCode(error, "EPERM");
449
+ }
450
+ }
@@ -65,6 +65,12 @@ export class ForwardingManager {
65
65
  return;
66
66
  }
67
67
  this.timer = setInterval(() => {
68
+ // Ahead of the processing guard: a session whose human is deliberating at
69
+ // a forwarded dialog holds `processInbox` open for as long as they take,
70
+ // and it is serving throughout. Refreshing behind the guard would let its
71
+ // announcement decay exactly when it is most demonstrably alive, and
72
+ // every other forwarding child would give up on it.
73
+ this.refreshServing();
68
74
  if (!this.context || this.processing) {
69
75
  return;
70
76
  }
@@ -107,6 +113,21 @@ export class ForwardingManager {
107
113
  });
108
114
  }
109
115
 
116
+ /**
117
+ * Re-announce the served session, keeping a decayable channel current.
118
+ *
119
+ * Separate from {@link announceServing} because that one detects a change to
120
+ * write its log line, and this one deliberately writes none — four review
121
+ * entries a second would drown the log the announcement exists to make
122
+ * readable.
123
+ */
124
+ private refreshServing(): void {
125
+ if (this.servingSessionId === null) {
126
+ return;
127
+ }
128
+ this.deps.serving.markServing(this.servingSessionId);
129
+ }
130
+
110
131
  /** Withdraw the published session, if any. */
111
132
  private withdrawServing(): void {
112
133
  const sessionId = this.servingSessionId;
@@ -1,3 +1,5 @@
1
+ import type { DecisionSource } from "#src/authority/decision-source";
2
+
1
3
  export type PermissionDecisionState =
2
4
  | "approved"
3
5
  | "approved_for_session"
@@ -26,8 +28,27 @@ export type PermissionPromptDecision = {
26
28
  * denial — a user who was never asked denied nothing (#719).
27
29
  */
28
30
  confirmationUnavailable?: true;
31
+ /**
32
+ * What decided this request, stamped by the site that decided it.
33
+ *
34
+ * Required: every decision names its decider, and the type is what
35
+ * guarantees it rather than a convention each producer has to remember — the
36
+ * same discipline `PromptPermissionDetails.payload` carries (#726).
37
+ */
38
+ decidedBy: DecisionSource;
29
39
  };
30
40
 
41
+ /**
42
+ * A decision before its decider is known.
43
+ *
44
+ * The inner producers — the dialog's decision model, the `select`/`input`
45
+ * fallback, the verdict mapper — state the outcome; which decider to attribute
46
+ * it to is settled one layer up, at the site that chose the producer. The same
47
+ * shape `GateBypass.decision` uses for the request id: a producer emits only
48
+ * what it knows.
49
+ */
50
+ export type UnattributedDecision = Omit<PermissionPromptDecision, "decidedBy">;
51
+
31
52
  export interface PermissionDecisionUi {
32
53
  select(title: string, options: string[]): Promise<string | undefined>;
33
54
  input(title: string, placeholder?: string): Promise<string | undefined>;
@@ -51,7 +72,7 @@ export function normalizePermissionDenialReason(
51
72
 
52
73
  export function createDeniedPermissionDecision(
53
74
  denialReason?: string,
54
- ): PermissionPromptDecision {
75
+ ): UnattributedDecision {
55
76
  const normalizedReason = normalizePermissionDenialReason(denialReason);
56
77
  return normalizedReason
57
78
  ? {
@@ -96,7 +117,7 @@ export async function requestPermissionDecisionFromUi(
96
117
  title: string,
97
118
  message: string,
98
119
  options?: RequestPermissionOptions,
99
- ): Promise<PermissionPromptDecision> {
120
+ ): Promise<UnattributedDecision> {
100
121
  const sessionOption = options?.sessionLabel ?? APPROVE_FOR_SESSION_OPTION;
101
122
  const decisionOptions = [
102
123
  APPROVE_OPTION,
@@ -1,4 +1,5 @@
1
1
  import { join } from "node:path";
2
+ import type { DecisionSource } from "#src/authority/decision-source";
2
3
  import type { PermissionUiPromptSource } from "#src/permission-events";
3
4
  import type { PromptPayload } from "#src/presentation/prompt-payload";
4
5
  import type { PermissionDecisionState } from "./permission-dialog";
@@ -163,6 +164,18 @@ export type ForwardedPermissionResponse = {
163
164
  denialReason?: string;
164
165
  responderSessionId: string;
165
166
  respondedAt: number;
167
+ /**
168
+ * What decided, inside the responding session (#726).
169
+ *
170
+ * `responderSessionId` names *where* the decision was made; this names
171
+ * *what* made it, which is the difference between a human at the parent's
172
+ * dialog and the parent's policy answering on their behalf.
173
+ *
174
+ * Optional for version-skew tolerance: an older responder omits it, and the
175
+ * requester records the hop with a `null` inner decision rather than
176
+ * rejecting the answer.
177
+ */
178
+ decidedBy?: DecisionSource;
166
179
  };
167
180
 
168
181
  export type PermissionForwardingLocation = {
@@ -188,7 +201,15 @@ export function normalizePermissionForwardingSessionId(
188
201
  return trimmed;
189
202
  }
190
203
 
191
- function encodeSessionIdForPath(sessionId: string): string {
204
+ /**
205
+ * Make a session id safe to name a path segment.
206
+ *
207
+ * Exported because the forwarding tree has two layouts keyed by session id —
208
+ * `sessions/<id>/` and the serving-heartbeat records beside it — and a second
209
+ * encoding would be a silent way for the two to disagree about which file
210
+ * belongs to which session.
211
+ */
212
+ export function encodeSessionIdForPath(sessionId: string): string {
192
213
  return encodeURIComponent(sessionId);
193
214
  }
194
215
 
@@ -4,10 +4,15 @@ import type {
4
4
  KeybindingsManager,
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import { type Component, matchesKey } from "@earendil-works/pi-tui";
7
+ import type {
8
+ DecisionSource,
9
+ UserDecisionSurface,
10
+ } from "#src/authority/decision-source";
7
11
  import {
8
12
  type PermissionPromptDecision,
9
13
  type RequestPermissionOptions,
10
14
  requestPermissionDecisionFromUi,
15
+ type UnattributedDecision,
11
16
  } from "#src/authority/permission-dialog";
12
17
  import {
13
18
  initialPromptState,
@@ -64,15 +69,23 @@ export interface PromptPreferences {
64
69
  *
65
70
  * The single entry the `LocalUserAuthorizer` calls; keeps the mode dispatch in
66
71
  * one place so the fallback and the inline component never both render.
72
+ *
73
+ * It is therefore also the one place that knows which surface the human
74
+ * answered on, so it is where the decision is attributed to that surface
75
+ * (#726). Having the dialog model and the fallback each name themselves would
76
+ * be two sites that must agree with this branch.
67
77
  */
68
- export function requestPermissionDecision(
78
+ export async function requestPermissionDecision(
69
79
  view: PermissionPromptView,
70
80
  title: string,
71
81
  payload: PromptPayload,
72
82
  options?: RequestPermissionOptions,
73
83
  ): Promise<PermissionPromptDecision> {
74
84
  if (view.mode === "tui") {
75
- return presentInlinePermissionPrompt(view, title, payload, options);
85
+ return attributeToHuman(
86
+ await presentInlinePermissionPrompt(view, title, payload, options),
87
+ "dialog",
88
+ );
76
89
  }
77
90
  // The fallback renders once and cannot re-render, so it neither paints nor
78
91
  // offers an expansion; it substitutes a nominal width for the terminal size
@@ -81,14 +94,25 @@ export function requestPermissionDecision(
81
94
  ...view.budget,
82
95
  width: FALLBACK_RENDER_WIDTH,
83
96
  });
84
- return requestPermissionDecisionFromUi(
85
- view.ui,
86
- title,
87
- rendered.lines.join("\n"),
88
- options,
97
+ return attributeToHuman(
98
+ await requestPermissionDecisionFromUi(
99
+ view.ui,
100
+ title,
101
+ rendered.lines.join("\n"),
102
+ options,
103
+ ),
104
+ "select",
89
105
  );
90
106
  }
91
107
 
108
+ function attributeToHuman(
109
+ decision: UnattributedDecision,
110
+ via: UserDecisionSurface,
111
+ ): PermissionPromptDecision {
112
+ const decidedBy: DecisionSource = { kind: "user", via };
113
+ return { ...decision, decidedBy };
114
+ }
115
+
92
116
  /** The width the `select`/`input` fallback renders against. */
93
117
  const FALLBACK_RENDER_WIDTH = 80;
94
118
 
@@ -113,13 +137,13 @@ export function presentInlinePermissionPrompt(
113
137
  title: string,
114
138
  payload: PromptPayload,
115
139
  options?: RequestPermissionOptions,
116
- ): Promise<PermissionPromptDecision> {
140
+ ): Promise<UnattributedDecision> {
117
141
  const config: PromptModelConfig = {
118
142
  doublePressToConfirm: view.doublePressToConfirm,
119
143
  sessionLabel: options?.sessionLabel ?? DEFAULT_SESSION_LABEL,
120
144
  sessionScope: options?.sessionScope,
121
145
  };
122
- return view.ui.custom<PermissionPromptDecision>(
146
+ return view.ui.custom<UnattributedDecision>(
123
147
  (tui, theme, keybindings, done) =>
124
148
  new PermissionPromptComponent(
125
149
  theme,
@@ -176,7 +200,7 @@ class PermissionPromptComponent implements Component {
176
200
  private readonly budget: RenderBudget,
177
201
  private readonly handleAppAction: (data: string) => boolean,
178
202
  private readonly requestRender: () => void,
179
- private readonly done: (decision: PermissionPromptDecision) => void,
203
+ private readonly done: (decision: UnattributedDecision) => void,
180
204
  ) {
181
205
  this.state = initialPromptState(config);
182
206
  }