@gotgenes/pi-permission-system 25.2.2 → 25.3.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 (37) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/config/config.example.json +3 -0
  4. package/dist/public.d.ts +156 -41
  5. package/docs/configuration.md +17 -2
  6. package/package.json +1 -1
  7. package/schemas/permissions.schema.json +16 -0
  8. package/src/access-intent/bash/command-enumeration.ts +45 -117
  9. package/src/access-intent/bash/wrapper-analysis.ts +335 -0
  10. package/src/authority/forwarded-request-server.ts +5 -14
  11. package/src/authority/local-user-authorizer.ts +2 -3
  12. package/src/authority/permission-prompt-component.ts +87 -47
  13. package/src/authority/permission-prompter.ts +9 -0
  14. package/src/config-loader.ts +2 -0
  15. package/src/config-schema.ts +14 -0
  16. package/src/extension-config.ts +10 -0
  17. package/src/handlers/gates/bash-command.ts +7 -2
  18. package/src/handlers/gates/bash-external-directory.ts +11 -6
  19. package/src/handlers/gates/bash-path.ts +10 -6
  20. package/src/handlers/gates/external-directory.ts +13 -8
  21. package/src/handlers/gates/path.ts +11 -14
  22. package/src/handlers/gates/skill-input.ts +5 -2
  23. package/src/handlers/gates/skill-read.ts +5 -6
  24. package/src/handlers/gates/tool.ts +10 -5
  25. package/src/index.ts +2 -0
  26. package/src/permission-prompts.ts +4 -72
  27. package/src/presentation/dialog-renderer.ts +404 -0
  28. package/src/presentation/forwarded-ask-payload.ts +45 -0
  29. package/src/presentation/legacy-message.ts +117 -0
  30. package/src/presentation/line-fitting.ts +27 -0
  31. package/src/presentation/path-ask-payload.ts +128 -0
  32. package/src/presentation/prompt-payload.ts +137 -0
  33. package/src/presentation/skill-ask-payload.ts +50 -0
  34. package/src/presentation/tool-ask-payload.ts +104 -0
  35. package/src/tool-preview-formatter.ts +1 -1
  36. package/src/types.ts +6 -0
  37. package/src/handlers/gates/external-directory-messages.ts +0 -28
@@ -0,0 +1,404 @@
1
+ import { describeBashCommandContext } from "#src/denial-messages";
2
+ import { fitLinesToWidth } from "#src/presentation/line-fitting";
3
+ import {
4
+ allEvidence,
5
+ type PromptPayload,
6
+ } from "#src/presentation/prompt-payload";
7
+
8
+ /**
9
+ * Render a {@link PromptPayload} for a human deciding an ask (ADR 0011 §5).
10
+ *
11
+ * The payload is complete by contract, so this is where elision happens: the
12
+ * dialog and the `select`/`input` fallback both render through here under
13
+ * their own budget, which is what makes a bounded prompt a property of the
14
+ * render rather than of what the gate assembled.
15
+ *
16
+ * The layout is one fact per line, `label : value`, labels aligned. A fact
17
+ * whose text an earlier line already carries is not repeated — a bash ask's
18
+ * gate surface is its tool name, and a generic tool ask's value is the tool —
19
+ * so every line the render spends states something new.
20
+ */
21
+ export function renderPromptDialog(
22
+ payload: PromptPayload,
23
+ budget: DialogBudget,
24
+ paint: HighlightPaint = plainText,
25
+ ): DialogView {
26
+ const core = coreFacts(payload).map((fact) =>
27
+ capField(fact, budget.fieldMaxWidth),
28
+ );
29
+ const evidence = evidenceFacts(payload).map((fact) =>
30
+ capField(fact, budget.fieldMaxWidth),
31
+ );
32
+ const blocks = layout(
33
+ [...core, ...evidence],
34
+ flaggedTexts(payload),
35
+ paint,
36
+ ).map((block) => fitLinesToWidth(block, budget.width));
37
+ const fitted = fitToRows(
38
+ blocks.slice(0, core.length).flat(),
39
+ blocks.slice(core.length),
40
+ budget.maxRows,
41
+ );
42
+ return {
43
+ lines: fitted.lines,
44
+ elided:
45
+ fitted.dropped || [...core, ...evidence].some((fact) => fact.clipped),
46
+ };
47
+ }
48
+
49
+ /**
50
+ * How much room a render has, as the operator configured it.
51
+ *
52
+ * Separate from the terminal width, which only the component rendering a frame
53
+ * knows — the configured half is read once per ask, the width once per frame.
54
+ */
55
+ export interface RenderBudget {
56
+ /** Maximum rendered rows. */
57
+ readonly maxRows: number;
58
+ /** Maximum characters of any one field's text. */
59
+ readonly fieldMaxWidth: number;
60
+ }
61
+
62
+ /** A {@link RenderBudget} against the width its rows are counted at. */
63
+ export interface DialogBudget extends RenderBudget {
64
+ /** Terminal width the lines are wrapped to, so a row count is meaningful. */
65
+ readonly width: number;
66
+ }
67
+
68
+ /**
69
+ * The budget when the operator configures neither field.
70
+ *
71
+ * Twenty-four rows plus the decision options and the hint fit a thirty-row
72
+ * terminal; four hundred characters is roughly four wrapped rows, which is what
73
+ * actually bounds a here-string command.
74
+ */
75
+ export const DEFAULT_RENDER_BUDGET: RenderBudget = {
76
+ maxRows: 24,
77
+ fieldMaxWidth: 400,
78
+ };
79
+
80
+ /** The two prompt-budget knobs, as the extension config carries them. */
81
+ export interface PromptBudgetConfig {
82
+ readonly promptMaxRows?: number;
83
+ readonly promptFieldMaxWidth?: number;
84
+ }
85
+
86
+ /** The configured budget, falling back per field to {@link DEFAULT_RENDER_BUDGET}. */
87
+ export function resolveRenderBudget(config: PromptBudgetConfig): RenderBudget {
88
+ return {
89
+ maxRows: config.promptMaxRows ?? DEFAULT_RENDER_BUDGET.maxRows,
90
+ fieldMaxWidth:
91
+ config.promptFieldMaxWidth ?? DEFAULT_RENDER_BUDGET.fieldMaxWidth,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Paints the flagged element — the command, path, or target the rule fired on.
97
+ *
98
+ * A render concern, so the fallback and the review log pass nothing: only the
99
+ * TUI has a theme to paint with.
100
+ */
101
+ export type HighlightPaint = (text: string) => string;
102
+
103
+ /** What a renderer produced, and whether it had to leave anything out. */
104
+ export interface DialogView {
105
+ /** Wrapped to the budget's width: each entry is one visual row. */
106
+ readonly lines: readonly string[];
107
+ /** True when any field was shortened or any entry dropped. */
108
+ readonly elided: boolean;
109
+ }
110
+
111
+ /**
112
+ * The budget that elides nothing — the complete view an operator must be able
113
+ * to reach while the decision is pending (ADR 0011 §4).
114
+ */
115
+ export function completeViewBudget(width: number): DialogBudget {
116
+ return {
117
+ maxRows: Number.POSITIVE_INFINITY,
118
+ fieldMaxWidth: Number.POSITIVE_INFINITY,
119
+ width,
120
+ };
121
+ }
122
+
123
+ const plainText: HighlightPaint = (text) => text;
124
+
125
+ /**
126
+ * What the ask is flagging.
127
+ *
128
+ * The decision-relevant value for every shape but one: a bash ask that escaped
129
+ * the working directory flags the paths it referenced, not the command that
130
+ * referenced them — the command is the context, and the paths are what the
131
+ * operator is ruling on.
132
+ */
133
+ function flaggedTexts(payload: PromptPayload): string[] {
134
+ if (payload.kind === "bash_external_directory") {
135
+ return allEvidence(payload, "external path").map((entry) => entry.text);
136
+ }
137
+ return payload.request.value === "" ? [] : [payload.request.value];
138
+ }
139
+
140
+ /** One rendered fact. */
141
+ interface Fact {
142
+ readonly label: string;
143
+ readonly text: string;
144
+ }
145
+
146
+ /** A fact narrowed to the budget, and whether that cost it anything. */
147
+ interface CappedFact extends Fact {
148
+ readonly clipped: boolean;
149
+ }
150
+
151
+ /**
152
+ * Narrow one field's text to the budget.
153
+ *
154
+ * A quantity bound applied uniformly, never a content filter: it does not read
155
+ * the value to decide what to hide, which is what keeps it a cap rather than
156
+ * redaction (ADR 0010). The marker is a bare ellipsis — a character or line
157
+ * count is a number the operator cannot act on, and ADR 0011 §4 rejects it in
158
+ * favour of reaching the complete view.
159
+ */
160
+ function capField(fact: Fact, fieldMaxWidth: number): CappedFact {
161
+ if (fact.text.length <= fieldMaxWidth) {
162
+ return { ...fact, clipped: false };
163
+ }
164
+ return {
165
+ ...fact,
166
+ text: `${fact.text.slice(0, fieldMaxWidth)}\u2026`,
167
+ clipped: true,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Fit the rendered blocks into the row budget.
173
+ *
174
+ * The core is exempt and the evidence is what gives way: §3 outranks §5, so a
175
+ * core that alone overruns the budget still renders whole — the field cap is
176
+ * what bounds it, and the row budget is what bounds the evidence. A drop costs
177
+ * one row for its marker, taken only when there is something to mark.
178
+ */
179
+ function fitToRows(
180
+ core: readonly string[],
181
+ evidence: readonly (readonly string[])[],
182
+ maxRows: number,
183
+ ): { lines: string[]; dropped: boolean } {
184
+ const total = evidence.reduce((rows, block) => rows + block.length, 0);
185
+ if (core.length + total <= maxRows) {
186
+ return { lines: [...core, ...evidence.flat()], dropped: false };
187
+ }
188
+ const limit = maxRows - ELISION_MARKER_ROWS;
189
+ const lines = [...core];
190
+ for (const block of evidence) {
191
+ // An entry is shown whole or not at all: half a path is worse evidence
192
+ // than none, and the reader cannot tell the halves apart.
193
+ if (lines.length + block.length > limit) {
194
+ break;
195
+ }
196
+ lines.push(...block);
197
+ }
198
+ if (lines.length < maxRows) {
199
+ lines.push(ELISION_MARKER);
200
+ }
201
+ return { lines, dropped: true };
202
+ }
203
+
204
+ /**
205
+ * What an elision states: that there is more, and nothing else.
206
+ *
207
+ * Character and line counts were considered and rejected (ADR 0011 §4) — they
208
+ * are a number the operator cannot act on, and they spend budget the evidence
209
+ * itself should hold.
210
+ */
211
+ const ELISION_MARKER = "\u2026";
212
+ const ELISION_MARKER_ROWS = 1;
213
+
214
+ /**
215
+ * The invariant core (ADR 0011 §3), in reading order: who is asking, what they
216
+ * called, what gated it, the decision-relevant value, and what will actually
217
+ * run.
218
+ */
219
+ function coreFacts(payload: PromptPayload): Fact[] {
220
+ const { request } = payload;
221
+ const facts: Fact[] = [];
222
+ const requester = requesterFact(payload);
223
+ if (requester) {
224
+ facts.push(requester);
225
+ }
226
+ if (request.toolName !== null) {
227
+ facts.push({ label: "tool", text: toolText(payload) });
228
+ }
229
+ // The surface is stated already when it *is* the tool name (a bash ask) or
230
+ // when it is the word the value line is labelled with (a path ask reads
231
+ // `path : /tmp/x`), so a line for it would repeat rather than add.
232
+ const label = valueLabel(payload);
233
+ if (request.surface !== request.toolName && request.surface !== label) {
234
+ facts.push({ label: "surface", text: request.surface });
235
+ }
236
+ if (request.matchedPattern !== null) {
237
+ facts.push({ label: "rule", text: request.matchedPattern });
238
+ }
239
+ if (request.value !== "" && request.value !== request.toolName) {
240
+ facts.push({ label, text: request.value });
241
+ }
242
+ if (request.executedUnit !== null) {
243
+ facts.push({ label: "runs", text: request.executedUnit });
244
+ }
245
+ const context = describeBashCommandContext(
246
+ request.commandContext ?? undefined,
247
+ );
248
+ if (context !== undefined) {
249
+ facts.push({ label: "context", text: context });
250
+ }
251
+ return facts;
252
+ }
253
+
254
+ /**
255
+ * The decision evidence, in payload order.
256
+ *
257
+ * An entry's `detail` rides its own line rather than becoming a second entry,
258
+ * so an elision can never show a path while dropping what it resolves to.
259
+ */
260
+ function evidenceFacts(payload: PromptPayload): Fact[] {
261
+ return payload.evidence.map((entry) => ({
262
+ label: entry.label,
263
+ text:
264
+ entry.detail === null ? entry.text : `${entry.text} → ${entry.detail}`,
265
+ }));
266
+ }
267
+
268
+ /**
269
+ * Who is asking.
270
+ *
271
+ * A forwarded ask always names its requester — that the ask came from a
272
+ * subagent is itself a core fact — while an unnamed local requester states
273
+ * nothing, and a line asserting the default would spend a row saying so.
274
+ */
275
+ function requesterFact(payload: PromptPayload): Fact | undefined {
276
+ const { agentName, forwarded, sessionId } = payload.request.requester;
277
+ if (!forwarded) {
278
+ return agentName ? { label: "agent", text: agentName } : undefined;
279
+ }
280
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || intentional: a version-skewed request carries "" rather than null
281
+ const name = agentName || "unknown";
282
+ return {
283
+ label: "subagent",
284
+ text: sessionId ? `${name} · session ${sessionId}` : name,
285
+ };
286
+ }
287
+
288
+ /** The gated tool, and the name the agent actually called when they differ. */
289
+ function toolText(payload: PromptPayload): string {
290
+ const { toolName, invokedToolName } = payload.request;
291
+ return invokedToolName === null
292
+ ? String(toolName)
293
+ : `${String(toolName)} (invoked as ${invokedToolName})`;
294
+ }
295
+
296
+ /** What the decision-relevant value is called, per ask shape. */
297
+ function valueLabel(payload: PromptPayload): string {
298
+ switch (payload.kind) {
299
+ case "bash":
300
+ case "bash_external_directory":
301
+ return "command";
302
+ case "mcp":
303
+ return "target";
304
+ case "tool":
305
+ return "tool";
306
+ case "path":
307
+ case "external_directory":
308
+ return "path";
309
+ case "skill":
310
+ case "skill_read":
311
+ return "skill";
312
+ case "forwarded":
313
+ return forwardedValueLabel(payload.request.surface);
314
+ }
315
+ }
316
+
317
+ /**
318
+ * A forwarded request carries the child's *display* projection — its tool name
319
+ * as the surface — rather than the child's own payload, so the label is
320
+ * inferred from it and falls back to a neutral one.
321
+ *
322
+ * Dissolves when the payload replaces `message` on the wire (#745): the
323
+ * serving node will then hold the child's real `kind`.
324
+ */
325
+ function forwardedValueLabel(surface: string): string {
326
+ switch (surface) {
327
+ case "bash":
328
+ return "command";
329
+ case "skill":
330
+ return "skill";
331
+ default:
332
+ return "value";
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Align the labels into a `label : value` column.
338
+ *
339
+ * A field carrying its own newlines (a here-string, a multi-line preview)
340
+ * continues under the column rather than back at the margin, so the eye can
341
+ * still tell a continuation from the next fact.
342
+ */
343
+ function layout(
344
+ facts: readonly Fact[],
345
+ flagged: readonly string[],
346
+ paint: HighlightPaint,
347
+ ): string[][] {
348
+ const width = Math.max(0, ...facts.map((fact) => fact.label.length));
349
+ const indent = " ".repeat(width + 3);
350
+ return facts.map((fact) => {
351
+ // A fact that *is* the flagged element paints whole; any other line paints
352
+ // the whole-token occurrences of it, so `ls` stays plain inside `lsof`.
353
+ const highlight = flagged.includes(fact.text)
354
+ ? paint
355
+ : (line: string) => paintTokens(line, flagged, paint);
356
+ return fact.text
357
+ .split("\n")
358
+ .map((line, index) =>
359
+ index === 0
360
+ ? `${fact.label.padEnd(width)} : ${highlight(line)}`
361
+ : indent + highlight(line),
362
+ );
363
+ });
364
+ }
365
+
366
+ /** Characters a path, command, or target may contain, so a match is a whole token. */
367
+ const TOKEN_CHARACTER = /[\w/.-]/;
368
+
369
+ /** Paint every whole-token occurrence of each flagged text within one line. */
370
+ function paintTokens(
371
+ line: string,
372
+ flagged: readonly string[],
373
+ paint: HighlightPaint,
374
+ ): string {
375
+ return flagged.reduce(
376
+ (painted, needle) => paintOccurrences(painted, needle, paint),
377
+ line,
378
+ );
379
+ }
380
+
381
+ function paintOccurrences(
382
+ line: string,
383
+ needle: string,
384
+ paint: HighlightPaint,
385
+ ): string {
386
+ if (needle === "" || needle.includes("\n")) {
387
+ return line;
388
+ }
389
+ let result = "";
390
+ let cursor = 0;
391
+ for (
392
+ let at = line.indexOf(needle, cursor);
393
+ at !== -1;
394
+ at = line.indexOf(needle, cursor)
395
+ ) {
396
+ const end = at + needle.length;
397
+ const whole =
398
+ !TOKEN_CHARACTER.test(line[at - 1] ?? " ") &&
399
+ !TOKEN_CHARACTER.test(line[end] ?? " ");
400
+ result += line.slice(cursor, at) + (whole ? paint(needle) : needle);
401
+ cursor = end;
402
+ }
403
+ return result + line.slice(cursor);
404
+ }
@@ -0,0 +1,45 @@
1
+ import type { ForwardedPermissionRequest } from "#src/authority/permission-forwarding";
2
+ import type { PromptPayload } from "#src/presentation/prompt-payload";
3
+
4
+ /**
5
+ * Build the payload for an ask forwarded up from a subagent.
6
+ *
7
+ * The child still ships a pre-rendered sentence, so the serving node carries it
8
+ * as a single evidence entry rather than inventing facts it was not sent: what
9
+ * arrives is prose, and calling it anything else would be a fiction the
10
+ * bounded renderers would then have to trust.
11
+ *
12
+ * When the payload replaces `message` on the wire, this builder projects the
13
+ * child's own payload instead, and the serving node renders the child's facts
14
+ * under its own budget — which is what makes a forwarded ask and a local one
15
+ * consistent for the first time (ADR 0011 §2).
16
+ *
17
+ * A request missing a field renders from whatever it does carry: fail-closed
18
+ * applies to presentation as it does to policy, so a version-skewed ask still
19
+ * reaches the human rather than resolving without one (ADR 0011 §9).
20
+ */
21
+ export function buildForwardedAskPayload(
22
+ request: ForwardedPermissionRequest,
23
+ ): PromptPayload {
24
+ return {
25
+ kind: "forwarded",
26
+ request: {
27
+ requester: {
28
+ agentName: request.requesterAgentName,
29
+ forwarded: true,
30
+ sessionId: request.requesterSessionId,
31
+ },
32
+ // The child's display projection: what the ask was about, as the child's
33
+ // own gate named it.
34
+ surface: request.surface ?? "",
35
+ toolName: null,
36
+ invokedToolName: null,
37
+ value: request.value ?? "",
38
+ matchedPattern: null,
39
+ commandContext: null,
40
+ executedUnit: null,
41
+ },
42
+ evidence: [{ label: "requested", text: request.message, detail: null }],
43
+ annotations: [],
44
+ };
45
+ }
@@ -0,0 +1,117 @@
1
+ import { matchQualifier, resolvesToSuffix } from "#src/denial-messages";
2
+ import {
3
+ allEvidence,
4
+ findEvidence,
5
+ type PromptPayload,
6
+ } from "#src/presentation/prompt-payload";
7
+
8
+ /**
9
+ * Render the flat `message` string every consumer still reads.
10
+ *
11
+ * Transitional, and deliberately the *only* place that string is produced: it
12
+ * reads nothing but the payload, so the existing prompt-text tests are the
13
+ * proof that the payload carries everything the six former assemblers said.
14
+ *
15
+ * The bounded renderers replace it consumer by consumer — the dialog and the
16
+ * fallback first, then the wire and the broadcast, then the review log — and
17
+ * this module goes when the last `message` reader does. Its coupling to the
18
+ * evidence labels the builders emit is the price of that byte-for-byte
19
+ * equivalence, and it is why it is scoped to the transition.
20
+ */
21
+ export function renderLegacyMessage(payload: PromptPayload): string {
22
+ const { request } = payload;
23
+ const subject = request.requester.agentName
24
+ ? `Agent '${request.requester.agentName}'`
25
+ : "Current agent";
26
+
27
+ switch (payload.kind) {
28
+ case "bash":
29
+ return `${subject} requested bash command '${request.value}'${bashQualifier(payload)}${fullCommandSuffix(payload)}. Allow this command?`;
30
+ case "mcp":
31
+ return `${subject} requested MCP target '${request.value}'${patternSuffix(payload)}${inputSuffix(payload)}. Allow this call?`;
32
+ case "tool":
33
+ return `${subject} requested tool '${request.value}'${patternSuffix(payload)}${inputSuffix(payload)}. Allow this call?`;
34
+ case "path":
35
+ return `${subject} requested tool '${request.toolName}' for path '${request.value}'. Allow this path access?`;
36
+ case "external_directory":
37
+ return `${subject} requested tool '${request.toolName}' for path '${request.value}'${resolvedSuffix(payload)} outside working directory '${workingDirectory(payload)}'. Allow this external directory access?`;
38
+ case "bash_external_directory":
39
+ return `${subject} requested bash command '${request.value}' which references path(s) outside working directory '${workingDirectory(payload)}': ${externalPathList(payload)}. Allow this external directory access?`;
40
+ case "skill":
41
+ return `${subject} requested skill '${request.value}'. Allow loading this skill?`;
42
+ case "skill_read":
43
+ return `${subject} requested access to skill '${request.value}' via '${textOf(payload, "read path")}'. Allow this read?`;
44
+ case "forwarded":
45
+ return renderForwarded(payload);
46
+ }
47
+ }
48
+
49
+ // ── Per-kind fragments ──────────────────────────────────────────────────────
50
+
51
+ /** The bash parenthetical: the matched rule plus, when nested, its context. */
52
+ function bashQualifier(payload: PromptPayload): string {
53
+ const qualifier = matchQualifier(
54
+ payload.request.matchedPattern ?? undefined,
55
+ payload.request.commandContext ?? undefined,
56
+ );
57
+ return qualifier ? ` ${qualifier}` : "";
58
+ }
59
+
60
+ /** ` (matched '<pattern>')` for the non-bash surfaces. */
61
+ function patternSuffix(payload: PromptPayload): string {
62
+ const { matchedPattern } = payload.request;
63
+ return matchedPattern ? ` (matched '${matchedPattern}')` : "";
64
+ }
65
+
66
+ /** The enclosing command, when the gated unit is only part of it. */
67
+ function fullCommandSuffix(payload: PromptPayload): string {
68
+ const full = findEvidence(payload, "full command");
69
+ return full ? ` (full command: '${full.text}')` : "";
70
+ }
71
+
72
+ /** The per-tool input preview, when the formatter produced one. */
73
+ function inputSuffix(payload: PromptPayload): string {
74
+ const preview = findEvidence(payload, "input");
75
+ return preview ? ` ${preview.text}` : "";
76
+ }
77
+
78
+ /** ` (resolves to '<canonical>')` when the alias names a distinct location. */
79
+ function resolvedSuffix(payload: PromptPayload): string {
80
+ return resolvesToSuffix(findEvidence(payload, "resolves to")?.text);
81
+ }
82
+
83
+ /** The comma-joined external paths, each with its canonical alias. */
84
+ function externalPathList(payload: PromptPayload): string {
85
+ return allEvidence(payload, "external path")
86
+ .map(
87
+ (entry) => `${entry.text}${resolvesToSuffix(entry.detail ?? undefined)}`,
88
+ )
89
+ .join(", ");
90
+ }
91
+
92
+ function workingDirectory(payload: PromptPayload): string {
93
+ return textOf(payload, "working directory");
94
+ }
95
+
96
+ /**
97
+ * The child's ask, prefixed with its provenance.
98
+ *
99
+ * Until the payload replaces `message` on the wire, a forwarded request carries
100
+ * the child's pre-rendered sentence, which arrives as a single evidence entry.
101
+ */
102
+ function renderForwarded(payload: PromptPayload): string {
103
+ const { requester } = payload.request;
104
+ return [
105
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || intentional: a version-skewed request carries "" rather than null
106
+ `Subagent '${requester.agentName || "unknown"}' requested permission.`,
107
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || intentional: a version-skewed request carries "" rather than null
108
+ `Session ID: ${requester.sessionId || "unknown"}`,
109
+ "",
110
+ textOf(payload, "requested"),
111
+ ].join("\n");
112
+ }
113
+
114
+ /** The text of an evidence entry the render requires, or the empty string. */
115
+ function textOf(payload: PromptPayload, label: string): string {
116
+ return findEvidence(payload, label)?.text ?? "";
117
+ }
@@ -0,0 +1,27 @@
1
+ import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
+
3
+ /**
4
+ * Fit rendered lines to a terminal width, so each returned entry is a single
5
+ * visual row no wider than `width`.
6
+ *
7
+ * Long lines are wrapped rather than clipped so no content is lost; the final
8
+ * `truncateToWidth` guards the edge cases `wrapTextWithAnsi` cannot split (a
9
+ * lone wide grapheme). A width of zero or less yields no rows.
10
+ *
11
+ * Shared by the `ctx.ui.custom` dialog — whose contract requires it — and by
12
+ * any renderer that must count rows, since a row count is only meaningful
13
+ * after wrapping.
14
+ */
15
+ export function fitLinesToWidth(
16
+ lines: readonly string[],
17
+ width: number,
18
+ ): string[] {
19
+ if (width <= 0) {
20
+ return [];
21
+ }
22
+ return lines.flatMap((line) =>
23
+ wrapTextWithAnsi(line, width).map((wrapped) =>
24
+ truncateToWidth(wrapped, width),
25
+ ),
26
+ );
27
+ }