@yagni-app/code-staging 1.1.4-staging.1419.1 → 1.1.4-staging.1420.1

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.
@@ -151,6 +151,71 @@ export declare function matchesCompoundGrants(command: string, grants: readonly
151
151
  export declare function validateGrant(command: string, policy: ExecPolicy, repoKey: string): ApprovedPrefixGrant | null;
152
152
  /** Human label for the remember option: "git push …". */
153
153
  export declare function describePrefix(pattern: string[]): string;
154
+ /**
155
+ * The custom-prefix field's seed (the editable input's initial value —
156
+ * Claude's initialValue): the prefix the user most plausibly wants to
157
+ * narrow. Single command → its own derived prefix; compound → the first
158
+ * PROMPT-BAND uncovered segment's prefix (Claude parity: their compound
159
+ * suggestions name only the segments needing approval — a `cd`/read-only
160
+ * rider is not what the user is being asked about). Falls back to the raw
161
+ * command only when nothing derivable exists (Claude's editable-field
162
+ * final fallback — the user narrows from the full text). NEVER the
163
+ * multi-seed join: "cd git push grep" is not a prefix of anything and
164
+ * editing it from scratch is the dead-end the field exists to prevent.
165
+ * PURE — a throwing classify counts as seedable (the LADDER fences, the
166
+ * seed does not).
167
+ */
168
+ export declare function describeSeedPrefix(command: string, uncoveredSegments: readonly string[], policy: ExecPolicy): string;
169
+ /**
170
+ * The custom-prefix acceptance unit (the decision-(a) fill): given the
171
+ * user's typed word (as a grant) and the command it was typed for, return
172
+ * the complete remember unit — the word's grant PLUS the ladder's
173
+ * self-match-proved seeds for every REMAINING uncovered segment — or null
174
+ * when the word covers nothing, a segment stays unfillable, or the filled
175
+ * set cannot cover the whole compound. PURE — extracted from the gate so
176
+ * every exclusion branch is directly testable. The unit is ONE decision
177
+ * (saved atomically at the gate); persistence itself is per-grant
178
+ * fail-soft, the same pattern as the multi-seed remember path.
179
+ *
180
+ * The user-typed grant must already carry repoKey (it matches at evaluation
181
+ * time); the returned unit's fill seeds carry repoKey/addedAt/cwd, ready
182
+ * to persist.
183
+ */
184
+ export declare function computeCustomPrefixUnit(args: {
185
+ command: string;
186
+ customGrant: ApprovedPrefixGrant;
187
+ existingGrants: readonly ApprovedPrefixGrant[];
188
+ uncoveredCount: number;
189
+ policy: ExecPolicy;
190
+ repoKey: string;
191
+ cwd: string;
192
+ /** The ladder's fill preview for this command (validateGrantForEscape's
193
+ * token seeds, repoKey/addedAt/cwd already filled) — the SAME preview the
194
+ * gate uses for the title disclosure, passed in so the ladder runs ONCE
195
+ * per ask and disclosure/save agreement is structural, not incidental.
196
+ * Absent → computed here (tests can drive the seam standalone). */
197
+ fillSeeds?: readonly ApprovedPrefixGrant[];
198
+ }): ApprovedPrefixGrant[] | null;
199
+ /**
200
+ * The custom-prefix dialog's TITLE, derived from the SEED word (the
201
+ * overwhelmingly common case: the field opens pre-filled, and the user
202
+ * keeps or edits around it). The disclosure predicts the actual unit for
203
+ * the seed — running the same acceptance seam (computeCustomPrefixUnit)
204
+ * the save will run — so the title advertises exactly what persisting the
205
+ * seed would save, minus the seed's own grant. A user who types a wildly
206
+ * different word gets the after-the-fact save-notify naming the real unit.
207
+ * Returns null when no fill rides along (single commands, or the seed
208
+ * already covers everything) — the plain title.
209
+ * PURE.
210
+ */
211
+ export declare function describeCustomPrefixFill(command: string, seed: string, args: {
212
+ existingGrants: readonly ApprovedPrefixGrant[];
213
+ uncoveredCount: number;
214
+ policy: ExecPolicy;
215
+ repoKey: string;
216
+ cwd: string;
217
+ fillSeeds: readonly ApprovedPrefixGrant[];
218
+ }): string[] | null;
154
219
  /** Preserve the command and output target before a heredoc marker. */
155
220
  export declare function heredocPrefix(command: string): string | null;
156
221
  /**
@@ -441,6 +441,144 @@ export function validateGrant(command, policy, repoKey) {
441
441
  export function describePrefix(pattern) {
442
442
  return `${pattern.join(" ")} …`;
443
443
  }
444
+ /**
445
+ * The custom-prefix field's seed (the editable input's initial value —
446
+ * Claude's initialValue): the prefix the user most plausibly wants to
447
+ * narrow. Single command → its own derived prefix; compound → the first
448
+ * PROMPT-BAND uncovered segment's prefix (Claude parity: their compound
449
+ * suggestions name only the segments needing approval — a `cd`/read-only
450
+ * rider is not what the user is being asked about). Falls back to the raw
451
+ * command only when nothing derivable exists (Claude's editable-field
452
+ * final fallback — the user narrows from the full text). NEVER the
453
+ * multi-seed join: "cd git push grep" is not a prefix of anything and
454
+ * editing it from scratch is the dead-end the field exists to prevent.
455
+ * PURE — a throwing classify counts as seedable (the LADDER fences, the
456
+ * seed does not).
457
+ */
458
+ export function describeSeedPrefix(command, uncoveredSegments, policy) {
459
+ if (isSinglePlainCommand(command)) {
460
+ const pattern = derivePrefix(command);
461
+ if (pattern)
462
+ return pattern.join(" ");
463
+ }
464
+ // Compound (or a non-derivable single): the first prompt-band uncovered
465
+ // segment's prefix, skipping read-only riders (cd, cat, ls — the
466
+ // allow-classified segments the user is not being asked about). Each
467
+ // segment is CANONICALIZED first (the ladder's own posture): the raw
468
+ // segments arrive quote-preserving, so `git push 2>&1` reads as
469
+ // `git push 2>'&1'` — an operator-carrying string that neither
470
+ // classifies nor derives; canonicalization strips the safe redirect the
471
+ // same way matching will.
472
+ for (const seg of uncoveredSegments) {
473
+ const canonical = canonicalizeForGrants(seg);
474
+ if (!canonical || !isSinglePlainCommand(canonical))
475
+ continue;
476
+ let readonlySegment = false;
477
+ try {
478
+ readonlySegment = classifyCommand(canonical, policy).decision === "allow";
479
+ }
480
+ catch {
481
+ // classify-error counts as seedable — the ladder fences.
482
+ }
483
+ if (readonlySegment)
484
+ continue;
485
+ const pattern = derivePrefix(canonical);
486
+ if (pattern)
487
+ return pattern.join(" ");
488
+ }
489
+ return command;
490
+ }
491
+ /**
492
+ * The custom-prefix acceptance unit (the decision-(a) fill): given the
493
+ * user's typed word (as a grant) and the command it was typed for, return
494
+ * the complete remember unit — the word's grant PLUS the ladder's
495
+ * self-match-proved seeds for every REMAINING uncovered segment — or null
496
+ * when the word covers nothing, a segment stays unfillable, or the filled
497
+ * set cannot cover the whole compound. PURE — extracted from the gate so
498
+ * every exclusion branch is directly testable. The unit is ONE decision
499
+ * (saved atomically at the gate); persistence itself is per-grant
500
+ * fail-soft, the same pattern as the multi-seed remember path.
501
+ *
502
+ * The user-typed grant must already carry repoKey (it matches at evaluation
503
+ * time); the returned unit's fill seeds carry repoKey/addedAt/cwd, ready
504
+ * to persist.
505
+ */
506
+ export function computeCustomPrefixUnit(args) {
507
+ const { command, customGrant, existingGrants, uncoveredCount, policy, repoKey, cwd } = args;
508
+ // Non-token (literal) custom inputs keep the single-grant proof.
509
+ if (customGrant.pattern.length === 0) {
510
+ return matchesGrant(command, [customGrant], repoKey) ? [customGrant] : null;
511
+ }
512
+ const ev = evaluateCompoundForEscape(command, [...existingGrants, customGrant], repoKey, policy);
513
+ if (ev.forbidden)
514
+ return null;
515
+ if (ev.uncovered.length === 0)
516
+ return [customGrant];
517
+ // The word must cover AT LEAST ONE segment of this command — a word
518
+ // unrelated to every segment (typed "zzz" for a pnpm compound) is a dead
519
+ // grant no fill can legitimize; refuse it. The fill exists to cover the
520
+ // SIBLINGS of the user's word, never to smuggle it in.
521
+ if (ev.uncovered.length >= uncoveredCount)
522
+ return null;
523
+ // Fill from the ladder: seeds for the remaining uncovered segments. The
524
+ // ladder's seeds are hypothetical (empty repoKey) — fill repoKey/addedAt/
525
+ // cwd BEFORE the recheck so grant matching sees them as real grants for
526
+ // THIS repo, exactly as the remember resolution does.
527
+ const fillSeeds = args.fillSeeds ?? (validateGrantForEscape(command, policy, repoKey)?.seeds ?? [])
528
+ .filter((sd) => sd.pattern.length > 0)
529
+ .map((sd) => ({ ...sd, repoKey, addedAt: new Date().toISOString(), cwd }));
530
+ // A fill with no token seeds cannot complete the unit — refuse (the
531
+ // ladder's literal rung is not a token fill; the shapes it covers are
532
+ // the heredoc class, which the honest warn + re-offer handles).
533
+ if (fillSeeds.length === 0)
534
+ return null;
535
+ const recheck = evaluateCompoundForEscape(command, [...existingGrants, customGrant, ...fillSeeds], repoKey, policy);
536
+ if (recheck.forbidden || recheck.uncovered.length > 0)
537
+ return null;
538
+ // Dedupe: when the typed word is itself the derivable prefix of its
539
+ // segment (the common case — typing "git push"), the fill re-derives the
540
+ // same pattern. One grant per distinct pattern.
541
+ const seen = new Set();
542
+ const unit = [];
543
+ for (const grant of [customGrant, ...fillSeeds]) {
544
+ const key = grant.pattern.join(" ");
545
+ if (seen.has(key))
546
+ continue;
547
+ seen.add(key);
548
+ unit.push(grant);
549
+ }
550
+ return unit;
551
+ }
552
+ /**
553
+ * The custom-prefix dialog's TITLE, derived from the SEED word (the
554
+ * overwhelmingly common case: the field opens pre-filled, and the user
555
+ * keeps or edits around it). The disclosure predicts the actual unit for
556
+ * the seed — running the same acceptance seam (computeCustomPrefixUnit)
557
+ * the save will run — so the title advertises exactly what persisting the
558
+ * seed would save, minus the seed's own grant. A user who types a wildly
559
+ * different word gets the after-the-fact save-notify naming the real unit.
560
+ * Returns null when no fill rides along (single commands, or the seed
561
+ * already covers everything) — the plain title.
562
+ * PURE.
563
+ */
564
+ export function describeCustomPrefixFill(command, seed, args) {
565
+ const seedPattern = derivePrefix(seed);
566
+ if (!seedPattern)
567
+ return null;
568
+ const seedGrant = {
569
+ pattern: seedPattern,
570
+ repoKey: args.repoKey,
571
+ addedAt: new Date().toISOString(),
572
+ cwd: args.cwd,
573
+ };
574
+ const unit = computeCustomPrefixUnit({ command, customGrant: seedGrant, ...args });
575
+ if (!unit)
576
+ return null;
577
+ const fill = unit
578
+ .filter((g) => g.pattern.join(" ") !== seedPattern.join(" "))
579
+ .map((g) => g.pattern.join(" "));
580
+ return fill.length > 0 ? fill : null;
581
+ }
444
582
  // --- Escape-flow derivation (the unsandboxed-retry consent ladder) ---
445
583
  /** Preserve the command and output target before a heredoc marker. */
446
584
  export function heredocPrefix(command) {
@@ -26,7 +26,7 @@
26
26
  * When the mode leaves plan, stale plan-context messages are filtered out of
27
27
  * the context so the model doesn't keep believing it is restricted.
28
28
  */
29
- import { derivePrefix, describePrefix, evaluateCompoundForEscape, matchesGrant, matchesCompoundGrants, sandboxFailureKey, storagePrefix, validateGrantForAsk, validateGrantForEscape, } from "./approvedPrefixes.js";
29
+ import { derivePrefix, describePrefix, describeSeedPrefix, evaluateCompoundForEscape, matchesGrant, matchesCompoundGrants, sandboxFailureKey, storagePrefix, validateGrantForAsk, validateGrantForEscape, } from "./approvedPrefixes.js";
30
30
  import { consultPrefixMemoized, createPrefixMemo, PREFIX_CONSULT_GRACE_MS, segmentCannotYieldCandidate, } from "./prefixExtract.js";
31
31
  import { logEvent } from "../errorSink.js";
32
32
  import { makeBlessStore as defaultMakeBlessStore } from "../bless.js";
@@ -34,6 +34,8 @@ import { classifyCommand, DEFAULT_EXEC_POLICY } from "./execPolicy.js";
34
34
  import { evaluateRules } from "../permissionRules/engine.js";
35
35
  import { isDebug } from "../diagnostics.js";
36
36
  import { buildDiagnosticEvent, checkCircuitBreaker, DEFAULT_GUARDIAN_LIMITS, } from "./guardian.js";
37
+ import { askCustomPrefix, CUSTOM_PREFIX_CANCELLED } from "./prefixInput.js";
38
+ import { computeCustomPrefixUnit, describeCustomPrefixFill } from "./approvedPrefixes.js";
37
39
  export function createModeHolder(initial = "auto") {
38
40
  let current = initial;
39
41
  const listeners = new Set();
@@ -1865,18 +1867,63 @@ export function registerPermissionGate(pi, deps = {}) {
1865
1867
  // dismissed/user_reject.
1866
1868
  let retryErrored = false;
1867
1869
  if (!selectThrew && choice === customLabel) {
1868
- const seedText = seeds
1869
- ? (seeds.seeds[0]?.literal ?? seeds.seeds.map((s) => s.pattern.join(" ")).join(" "))
1870
- : command;
1870
+ // The seed is the honest single suggestion (Claude's initialValue):
1871
+ // the derived prefix for a single command, the first prompt-band
1872
+ // segment's prefix for a compound — never the multi-seed join
1873
+ // ("cd git push grep" is a prefix of nothing; the join made the
1874
+ // field unrecognizable AND uneditable from a useful base).
1875
+ const seedText = describeSeedPrefix(command, compound.uncovered, execPolicy);
1876
+ // The title DISCLOSES the fill up front (informed consent, no extra
1877
+ // click) — derived from the SEED word: the field opens pre-filled,
1878
+ // so the disclosure predicts the actual unit the acceptance seam
1879
+ // would save for that seed (sibling families only, minus the seed's
1880
+ // own grant). A user who types a wildly different word still gets the
1881
+ // after-the-fact save-notify naming the real unit. ONE ladder preview
1882
+ // feeds both the title and the seam's fill (the gate computes it;
1883
+ // computeCustomPrefixUnit consumes it) — the ladder runs once per
1884
+ // ask, and disclosure/save agreement is structural.
1885
+ const fillPreview = (validateGrantForEscape(command, execPolicy, repoKey)?.seeds ?? [])
1886
+ .filter((sd) => sd.pattern.length > 0)
1887
+ .map((sd) => ({ ...sd, repoKey, addedAt: new Date().toISOString(), cwd }));
1888
+ const fillLabels = describeCustomPrefixFill(command, seedText, {
1889
+ existingGrants: grants,
1890
+ uncoveredCount: compound.uncovered.length,
1891
+ policy: execPolicy,
1892
+ repoKey,
1893
+ cwd,
1894
+ fillSeeds: fillPreview,
1895
+ });
1896
+ const unitTitle = fillLabels !== null
1897
+ ? `Don't ask again for commands starting with (also saving: ${fillLabels.join(", ")})`
1898
+ : "Don't ask again for commands starting with";
1871
1899
  let custom;
1900
+ let cancelledInput = false;
1872
1901
  let inputThrew = false;
1873
1902
  try {
1874
- custom = await ctx.ui.input("Don't ask again for commands starting with", seedText, { ...(ctx.signal ? { signal: ctx.signal } : {}) });
1903
+ const resolution = await askCustomPrefix(ctx, unitTitle, seedText);
1904
+ if (resolution === CUSTOM_PREFIX_CANCELLED)
1905
+ cancelledInput = true;
1906
+ else if (resolution !== undefined)
1907
+ custom = resolution;
1875
1908
  }
1876
- catch {
1909
+ catch (err) {
1877
1910
  inputThrew = true;
1911
+ // Same discipline as every sibling dialog failure in this flow —
1912
+ // a thrown ask (the fallback ctx.ui.input included) must leave a
1913
+ // trail, error class only (never the thrown message). The phase
1914
+ // discriminator is a stable non-message field so multiple ask
1915
+ // failure lines in one turn are tellable apart.
1916
+ logEvent({
1917
+ source: "permission-rules",
1918
+ level: "warn",
1919
+ event: "gate_dialog_error",
1920
+ fields: {
1921
+ error: err instanceof Error ? err.constructor.name : typeof err,
1922
+ phase: "custom-ask",
1923
+ },
1924
+ });
1878
1925
  }
1879
- if (!inputThrew && custom !== undefined && custom.trim().length > 0) {
1926
+ if (!inputThrew && !cancelledInput && custom !== undefined && custom.trim().length > 0) {
1880
1927
  const trimmedCustom = custom.trim();
1881
1928
  // The custom field accepts two shapes:
1882
1929
  // (a) a derivable token pattern — a clean command word (`npx`,
@@ -1897,24 +1944,50 @@ export function registerPermissionGate(pi, deps = {}) {
1897
1944
  const customGrant = tokenPattern
1898
1945
  ? { pattern: tokenPattern, repoKey, addedAt: new Date().toISOString(), cwd }
1899
1946
  : { pattern: [], literal: trimmedCustom, repoKey, addedAt: new Date().toISOString(), cwd };
1900
- // Validation must be compound-aware: the escaped command is often a
1901
- // compound (`S=…; curl && echo done`), and matchesGrant alone can
1902
- // never match a single token grant against a compound. A bare-word
1903
- // token grant is valid when EVERY segment of the compound is covered
1904
- // by the token grant or an existing grant — exactly the ladder's
1905
- // rung-1 self-match, applied to the user's custom word.
1906
- const customValid = tokenPattern
1907
- ? (() => {
1908
- const evalPolicy = deps.policy?.execPolicy ?? DEFAULT_EXEC_POLICY;
1909
- const ev = evaluateCompoundForEscape(command, [...grants, customGrant], repoKey, evalPolicy);
1910
- return !ev.forbidden && ev.uncovered.length === 0;
1911
- })()
1912
- : matchesGrant(command, [customGrant], repoKey);
1913
- if (customValid) {
1914
- grants.push(customGrant);
1915
- persistGrantFailSoft(customGrant);
1947
+ // Compound acceptance is FILLING, not all-or-nothing (Claude
1948
+ // parity their compound per-subcommand rules save as one
1949
+ // decision; our analog: the custom word is the grant for the
1950
+ // segment it covers, and the ladder's own self-match-proved seeds
1951
+ // fill every REMAINING uncovered segment). Under the old
1952
+ // all-or-nothing validation a bare word could NEVER satisfy a
1953
+ // compound (the sibling segments stayed uncovered), so an
1954
+ // honestly-answered field was rejected and the ask re-fired
1955
+ // forever the live failure this flow fixes. The unit is ONE
1956
+ // decision saved here; persistence itself is per-grant fail-soft,
1957
+ // the same pattern as the multi-seed remember path (a mid-loop
1958
+ // persist failure leaves earlier grants saved — the warn trail
1959
+ // records it). The unit computation is PURE and every exclusion
1960
+ // branch is unit-tested directly (computeCustomPrefixUnit); a
1961
+ // segment the ladder cannot seed (heredoc/quoted shapes) still
1962
+ // refuses: fail closed, warn honestly, one re-offer (below).
1963
+ const customUnit = computeCustomPrefixUnit({
1964
+ command,
1965
+ customGrant,
1966
+ existingGrants: grants,
1967
+ uncoveredCount: compound.uncovered.length,
1968
+ policy: execPolicy,
1969
+ repoKey,
1970
+ cwd,
1971
+ fillSeeds: fillPreview,
1972
+ });
1973
+ if (customUnit) {
1974
+ for (const grantRecord of customUnit) {
1975
+ grants.push(grantRecord);
1976
+ persistGrantFailSoft(grantRecord);
1977
+ }
1916
1978
  rememberApproved(cwd, command);
1917
1979
  emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_approved_remembered", consulted: false, prefixConsult: foldStatus() });
1980
+ // Save-confirmation: the old flow's only success feedback was
1981
+ // silence, leaving the user asking "did it work?". Name what was
1982
+ // saved — the unit (custom word + filled siblings) — one line,
1983
+ // info tier, fail-soft.
1984
+ if (ctx.hasUI) {
1985
+ try {
1986
+ const savedLabels = customUnit.map((g) => g.pattern.length > 0 ? g.pattern.join(" ") : (g.literal ?? ""));
1987
+ ctx.ui.notify(`Saved "don't ask again" for: ${savedLabels.join(", ")}`, "info");
1988
+ }
1989
+ catch { /* notify must never gate */ }
1990
+ }
1918
1991
  return {};
1919
1992
  }
1920
1993
  // A custom prefix that does not cover this command is a dead rule —
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The custom-prefix field (the escape consent flow's editable input):
3
+ * pi's OWN shipped ExtensionInputComponent with exactly one delta — the
4
+ * input is pre-filled with the derived seed (Claude's initialValue; pi's
5
+ * stock component drops its placeholder, so the old `ctx.ui.input` dialog
6
+ * arrived empty and unlabeled while the turn's working indicator kept
7
+ * animating — a blocked turn cosplaying as a live one, the ticket's
8
+ * "the model went back to work" symptom).
9
+ *
10
+ * The gate's askCustomPrefix seam: TUI mounts the seeded surface via
11
+ * ctx.ui.custom; every other mode (RPC, desktop, headless harnesses)
12
+ * falls back to plain ctx.ui.input, where the corrected seed rides the
13
+ * placeholder (desktop's DialogCard renders it). ctx.mode is a hard
14
+ * guard — pi's RPC custom() RESOLVES undefined instantly (never throws),
15
+ * so an unguarded call would read as a cancelled input. A THROWN or
16
+ * no-mount custom() falls back to the plain input dialog WITH a
17
+ * gate_dialog_error trail line (error class only) — a broken mount must
18
+ * degrade visibly, not silently.
19
+ */
20
+ import { ExtensionInputComponent } from "@earendil-works/pi-coding-agent";
21
+ import type { Component } from "@earendil-works/pi-tui";
22
+ /** pi's input dialog, seeded. Subclass — not a reimplementation. */
23
+ export declare class SeededExtensionInput extends ExtensionInputComponent {
24
+ constructor(title: string, seed: string, onSubmit: (value: string) => void, onCancel: () => void);
25
+ }
26
+ /**
27
+ * The mount's resolution. A plain string is the typed prefix; CANCELLED is
28
+ * the user's Esc — DISTINCT from undefined, which pi's custom() resolves
29
+ * with when the harness has no custom mount (a bare `return undefined`
30
+ * path in rpc/custom-less hosts). Conflating the two made cancel re-open
31
+ * the plain input dialog (the cancel-twice bug).
32
+ */
33
+ export declare const CUSTOM_PREFIX_CANCELLED: unique symbol;
34
+ export type CustomPrefixResolution = string | typeof CUSTOM_PREFIX_CANCELLED | undefined;
35
+ /**
36
+ * The PRODUCTION submit mapping (shared, not duplicated): pi's component
37
+ * submits the raw field; this wrapper trims and maps an emptied field to
38
+ * the cancel sentinel (Claude's allowEmptySubmitToCancel). Exported so the
39
+ * gate's askCustomPrefix mount and the live-mount test install the SAME
40
+ * function — a production regression breaks both, per the review round.
41
+ */
42
+ export declare function mapCustomPrefixSubmit(value: string): string | typeof CUSTOM_PREFIX_CANCELLED;
43
+ type CustomMount = <T>(factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (r: T) => void) => Component) => Promise<T>;
44
+ interface AskCustomPrefixCtx {
45
+ mode?: string;
46
+ hasUI?: boolean;
47
+ signal?: AbortSignal;
48
+ ui: {
49
+ input: (title: string, placeholder?: string, opts?: {
50
+ signal?: AbortSignal;
51
+ }) => Promise<string | undefined>;
52
+ custom?: CustomMount;
53
+ setWorkingVisible?: (visible: boolean) => void;
54
+ };
55
+ }
56
+ export declare function askCustomPrefix(ctx: AskCustomPrefixCtx, title: string, seed: string): Promise<CustomPrefixResolution>;
57
+ export {};
58
+ //# sourceMappingURL=prefixInput.d.ts.map
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The custom-prefix field (the escape consent flow's editable input):
3
+ * pi's OWN shipped ExtensionInputComponent with exactly one delta — the
4
+ * input is pre-filled with the derived seed (Claude's initialValue; pi's
5
+ * stock component drops its placeholder, so the old `ctx.ui.input` dialog
6
+ * arrived empty and unlabeled while the turn's working indicator kept
7
+ * animating — a blocked turn cosplaying as a live one, the ticket's
8
+ * "the model went back to work" symptom).
9
+ *
10
+ * The gate's askCustomPrefix seam: TUI mounts the seeded surface via
11
+ * ctx.ui.custom; every other mode (RPC, desktop, headless harnesses)
12
+ * falls back to plain ctx.ui.input, where the corrected seed rides the
13
+ * placeholder (desktop's DialogCard renders it). ctx.mode is a hard
14
+ * guard — pi's RPC custom() RESOLVES undefined instantly (never throws),
15
+ * so an unguarded call would read as a cancelled input. A THROWN or
16
+ * no-mount custom() falls back to the plain input dialog WITH a
17
+ * gate_dialog_error trail line (error class only) — a broken mount must
18
+ * degrade visibly, not silently.
19
+ */
20
+ import { ExtensionInputComponent } from "@earendil-works/pi-coding-agent";
21
+ import { logEvent } from "../errorSink.js";
22
+ /** pi's input dialog, seeded. Subclass — not a reimplementation. */
23
+ export class SeededExtensionInput extends ExtensionInputComponent {
24
+ constructor(title, seed, onSubmit, onCancel) {
25
+ super(title, undefined, onSubmit, onCancel, undefined);
26
+ // The seed is the EDITABLE INITIAL VALUE (Claude's initialValue), not a
27
+ // placeholder: the user narrows from a real suggestion. The only delta
28
+ // from pi's component.
29
+ this.input.setValue(seed);
30
+ }
31
+ }
32
+ /**
33
+ * The mount's resolution. A plain string is the typed prefix; CANCELLED is
34
+ * the user's Esc — DISTINCT from undefined, which pi's custom() resolves
35
+ * with when the harness has no custom mount (a bare `return undefined`
36
+ * path in rpc/custom-less hosts). Conflating the two made cancel re-open
37
+ * the plain input dialog (the cancel-twice bug).
38
+ */
39
+ export const CUSTOM_PREFIX_CANCELLED = Symbol("custom-prefix-cancelled");
40
+ /**
41
+ * The PRODUCTION submit mapping (shared, not duplicated): pi's component
42
+ * submits the raw field; this wrapper trims and maps an emptied field to
43
+ * the cancel sentinel (Claude's allowEmptySubmitToCancel). Exported so the
44
+ * gate's askCustomPrefix mount and the live-mount test install the SAME
45
+ * function — a production regression breaks both, per the review round.
46
+ */
47
+ export function mapCustomPrefixSubmit(value) {
48
+ const trimmed = value.trim();
49
+ return trimmed.length > 0 ? trimmed : CUSTOM_PREFIX_CANCELLED;
50
+ }
51
+ export async function askCustomPrefix(ctx, title, seed) {
52
+ const fallbackInput = () => ctx.ui.input(title, seed, { ...(ctx.signal ? { signal: ctx.signal } : {}) });
53
+ // A dialog waiting on the human must not animate a working spinner —
54
+ // the ticket's "turn resumed" illusion. Suppress while the dialog is
55
+ // up; restore ONLY if the suppression call actually exists and ran
56
+ // (a host without the API — RPC — has no spinner to restore). Fail-soft
57
+ // both ways: UI calls never gate the consent flow.
58
+ let spinnerSuppressed = false;
59
+ try {
60
+ ctx.ui.setWorkingVisible?.(false);
61
+ spinnerSuppressed = true;
62
+ }
63
+ catch { /* UI must never gate */ }
64
+ try {
65
+ if (ctx.mode === "tui" && ctx.hasUI && typeof ctx.ui.custom === "function") {
66
+ try {
67
+ const result = await ctx.ui.custom((_tui, _theme, _kb, done) => {
68
+ return new SeededExtensionInput(title, seed, (value) => done(mapCustomPrefixSubmit(value)), () => done(CUSTOM_PREFIX_CANCELLED));
69
+ });
70
+ // undefined = the harness has NO custom mount (pi resolves
71
+ // custom() with undefined there) — fall back to the plain input.
72
+ // A real cancel arrives as the sentinel, never undefined.
73
+ if (result !== undefined)
74
+ return result;
75
+ return await fallbackInput();
76
+ }
77
+ catch (err) {
78
+ // A THROWN custom() is a broken mount degrading every escape ask —
79
+ // never silent. Error class only (never the thrown message; it can
80
+ // carry dialog-layer content), mirroring the gate's gate_dialog_error.
81
+ logEvent({
82
+ source: "permission-rules",
83
+ level: "warn",
84
+ event: "gate_dialog_error",
85
+ fields: { error: err instanceof Error ? err.constructor.name : typeof err, phase: "custom-mount" },
86
+ });
87
+ return await fallbackInput();
88
+ }
89
+ }
90
+ return await fallbackInput();
91
+ }
92
+ finally {
93
+ if (spinnerSuppressed) {
94
+ try {
95
+ ctx.ui.setWorkingVisible?.(true);
96
+ }
97
+ catch { /* UI must never gate */ }
98
+ }
99
+ }
100
+ }
101
+ //# sourceMappingURL=prefixInput.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.1.4-staging.1419.1",
3
+ "version": "1.1.4-staging.1420.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "72326f4909f8b24a2657ae8ddd9caa4f4ad2e614"
61
+ "yagniSourceSha": "651ed1d5bda0a76c0ce1257e0dc1a47769e2fe5c"
62
62
  }