@gotgenes/pi-permission-system 33.0.3 → 33.0.5

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [33.0.5](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v33.0.4...pi-permission-system-v33.0.5) (2026-09-20)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** stop projecting an interpreter's inline script as a path ([f919704](https://github.com/gotgenes/pi-packages/commit/f919704d029d6cae6027f50ce89cc49d76f35358)), closes [#863](https://github.com/gotgenes/pi-packages/issues/863)
14
+
15
+ ### Documentation
16
+
17
+ * **pi-permission-system:** record the interpreter script role in the roadmap and module tree ([630e29f](https://github.com/gotgenes/pi-packages/commit/630e29f2365fb1848a835078f5b28cb5462b0b94))
18
+
19
+ ## [33.0.4](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v33.0.3...pi-permission-system-v33.0.4) (2026-09-20)
20
+
21
+
22
+ ### Bug Fixes
23
+
24
+ * **pi-permission-system:** show a config warning present at session start ([65a59f7](https://github.com/gotgenes/pi-packages/commit/65a59f717fba9823cde171c9fbfdcd322dcc6ec7)), closes [#933](https://github.com/gotgenes/pi-packages/issues/933)
25
+
26
+ ### Documentation
27
+
28
+ * **pi-permission-system:** declare non-Pi hosts a conditional non-goal ([b9d645c](https://github.com/gotgenes/pi-packages/commit/b9d645cdf39df10afe5493ca1817df3a1c99c527)), closes [#922](https://github.com/gotgenes/pi-packages/issues/922)
29
+ * **pi-permission-system:** describe the config warning's delivery ([6a9024f](https://github.com/gotgenes/pi-packages/commit/6a9024f4db193f592a321a32c45979dedc2985e8)), closes [#933](https://github.com/gotgenes/pi-packages/issues/933)
30
+
8
31
  ## [33.0.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v33.0.2...pi-permission-system-v33.0.3) (2026-09-19)
9
32
 
10
33
 
package/README.md CHANGED
@@ -183,6 +183,11 @@ Hardening the gates against bypass, fail-closed corrections (breaking ones inclu
183
183
  - _Model judgment in the core._
184
184
  This package makes no LLM call and holds no model config; model-assisted judging attaches as a chain link over the authorizer seam instead.
185
185
  A link decides nothing until you name it in `authorizerChain`, and its `allow` on an excluded surface is downgraded to `defer`.
186
+ - _Supporting a non-Pi host._
187
+ Built and validated against Pi's extension API and no other: a fork that loads Pi extensions — [Oh My Pi](https://github.com/can1357/oh-my-pi) among them — diverges in payload shape, tool vocabulary, and approval authority, on its own release schedule.
188
+ Input that violates a contract this package already reads is hardened against anyway, because defensive normalization is correct whoever sent it.
189
+ Modeling a foreign host's semantics is not, because a guarantee that cannot be executed against is worse than a declined one.
190
+ This one is conditional rather than permanent — the architecture doc names the five conditions that would make a second host a goal.
186
191
 
187
192
  The [architecture doc](https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/architecture/architecture.md#scope-and-non-goals) carries the full inventory, with the decision record behind each entry.
188
193
 
@@ -194,6 +199,7 @@ The companion question — whether a capability model replaces the actor-keyed s
194
199
  **Where adjacent requests belong.**
195
200
  True isolation of a permitted action → an agent sandbox, which this package's scope decisions are exported to rather than duplicated in.
196
201
  Model-assisted judging of an `ask` → a chain link over the authorizer seam; [@gotgenes/pi-permission-model-judge](https://www.npmjs.com/package/@gotgenes/pi-permission-model-judge) is the first-party one, and judges mistyped paths.
202
+ A non-Pi host's own payload shapes and tool formats → that host's Pi-compatibility layer, where one fix reaches every Pi extension at once instead of one.
197
203
  Approve-and-steer, edit diffs, and risk explanations → a downstream package over the `permissions:decision` event and the presentation seams.
198
204
 
199
205
  ## Documentation
@@ -118,6 +118,7 @@ For allowlisted commands, all non-flag positional arguments are assumed to be pa
118
118
  **This extension** extracts path candidates from all commands generically, then applies additional intelligence:
119
119
 
120
120
  - A `PATTERN_FIRST_COMMANDS` map understands flag arity for `sed`, `awk`, `grep`, `rg`, and similar tools, distinguishing inline patterns/scripts from file arguments to avoid false positives.
121
+ The same table reads an interpreter's inline script (`node -e`, `bun --eval`, `python3 -c`, `perl -e`, `ruby -e`) as a script rather than an operand, while leaving a script *file* (`node build.js`) an operand.
121
122
  - Redirect destinations (`> /path/to/file`) are extracted.
122
123
  - Heredoc bodies, comments, and variable assignments are skipped.
123
124
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "33.0.3",
3
+ "version": "33.0.5",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,10 +36,11 @@ export interface PathToken {
36
36
  * {@link SKIP_SUBTREE_TYPES} check: `heredoc_body` is in both sets, and the
37
37
  * host reading is the one that must win.
38
38
  *
39
- * For commands in `PATTERN_FIRST_COMMANDS`, uses position-based
40
- * argument skipping to avoid collecting inline patterns/scripts
41
- * as path candidates. For all other commands, collects all
42
- * arguments generically.
39
+ * For commands in `PATTERN_FIRST_COMMANDS`, uses position- and role-based
40
+ * argument skipping to avoid collecting inline patterns/scripts as path
41
+ * candidates — a leading pattern positional for a matching tool, a
42
+ * `script`-role flag's argument for an interpreter. For all other commands,
43
+ * collects all arguments generically.
43
44
  */
44
45
  export function collectPathCandidateTokens(node: TSNode): PathToken[] {
45
46
  if (node.type === "command") return collectCommandTokens(node);
@@ -499,19 +500,124 @@ const SD_CONFIG: PatternCommandConfig = {
499
500
  };
500
501
 
501
502
  /**
502
- * Commands whose first N positional arguments are inline patterns/scripts,
503
- * not filesystem paths. The map stores per-command flag configuration so
504
- * the walker can correctly identify which arguments are consumed by flags
505
- * vs. which are positional.
503
+ * An interpreter takes its inline script from a flag and never from a leading
504
+ * positional, so `patternPositionals: 0` — `node build.js /tmp/x` names two
505
+ * real operands and no script.
506
+ *
507
+ * Verified by execution on macOS, 2026-09-20, node v26.9.0: `node -e`,
508
+ * `node --eval`, `node --eval='…'`, `node -p '1+1'` — `2`, and
509
+ * `node --print '2+2'` — `4` all run their argument as the program.
510
+ * `node -p t.js` evaluates `t.js` as *source* rather than running the file
511
+ * (`[eval]:1 / t.js / ^`), so `-p` consumes its argument unconditionally.
512
+ */
513
+ const NODE_CONFIG: PatternCommandConfig = {
514
+ flags: new Map<string, PatternFlagRole>([
515
+ ["-e", "script"],
516
+ ["--eval", "script"],
517
+ ["-p", "script"],
518
+ ["--print", "script"],
519
+ ]),
520
+ patternPositionals: 0,
521
+ };
522
+
523
+ /**
524
+ * `bun` asserts the same four spellings as `node` and gets its own object
525
+ * rather than sharing one, because the table's rule is a shared *parser* and
526
+ * not a shared spelling — the two are different binaries (#823).
527
+ *
528
+ * Verified by execution, bun 1.4.2: `bun -e`, `bun --eval`, `bun -p '1+1'`
529
+ * — `2`, `bun --print '3+3'` — `6`; `bun -p` with no value errors
530
+ * `The argument '-p' requires a value but none was supplied.`, so it consumes
531
+ * unconditionally.
532
+ */
533
+ const BUN_CONFIG: PatternCommandConfig = {
534
+ flags: new Map<string, PatternFlagRole>([
535
+ ["-e", "script"],
536
+ ["--eval", "script"],
537
+ ["-p", "script"],
538
+ ["--print", "script"],
539
+ ]),
540
+ patternPositionals: 0,
541
+ };
542
+
543
+ /**
544
+ * `python` and `python3` share one object because they are the same
545
+ * interpreter family: every implementation either name reaches is a
546
+ * CPython-compatible front end where `-c` takes the following argument.
547
+ *
548
+ * Verified by execution, python3 3.14.7: `python3 -c 'print("PC-OK")'`, and
549
+ * `python3 -cu 'print("x")'` raises from `File "<string>", line 1` — the
550
+ * glued `u` is evaluated as the script, which is the getopt semantics the
551
+ * existing glued-value rule already models. No `python` binary exists on the
552
+ * authoring host, so its row rests on the family argument rather than a run.
553
+ */
554
+ const PYTHON_CONFIG: PatternCommandConfig = {
555
+ flags: new Map<string, PatternFlagRole>([["-c", "script"]]),
556
+ patternPositionals: 0,
557
+ };
558
+
559
+ /**
560
+ * Verified by execution, perl 5.34.1: `perl -e 'print "PE-OK\n"'` and
561
+ * `perl -E 'say "PE2-OK"'`; `perl -e` with nothing after it errors
562
+ * `No code specified for -e.`, so both consume unconditionally.
563
+ *
564
+ * `-p` and `-n` are deliberately absent. They take no argument of their own,
565
+ * and the cluster spelling that carries the script (`perl -pe 's|a|b|'`) is
566
+ * looked up as `-p` by the glued rule's `text.slice(0, 2)` — so listing `-p`
567
+ * would consume the following word on the *separated* spelling too and drop a
568
+ * real operand, the direction ADR 0009 forbids.
569
+ */
570
+ const PERL_CONFIG: PatternCommandConfig = {
571
+ flags: new Map<string, PatternFlagRole>([
572
+ ["-e", "script"],
573
+ ["-E", "script"],
574
+ ]),
575
+ patternPositionals: 0,
576
+ };
577
+
578
+ /**
579
+ * Verified by execution, ruby 4.0.7: `ruby -e 'puts "RE-OK"'`.
580
+ *
581
+ * `-E` is deliberately **not** listed, though `perl` lists it: on `ruby` it is
582
+ * `--encoding`, not a script flag. `ruby -E utf-8 -e 'puts "RE2-OK"'` runs,
583
+ * proving `-E` consumed `utf-8` and left the script to `-e`. Leaving it
584
+ * unlisted over-surfaces `utf-8` as a token that names nothing, which the
585
+ * existence probe discards — the recoverable direction.
586
+ */
587
+ const RUBY_CONFIG: PatternCommandConfig = {
588
+ flags: new Map<string, PatternFlagRole>([["-e", "script"]]),
589
+ patternPositionals: 0,
590
+ };
591
+
592
+ /**
593
+ * Commands whose leading positional arguments are inline patterns/scripts
594
+ * rather than filesystem paths, and commands whose inline script arrives
595
+ * through a flag. The map stores per-command flag configuration so the walker
596
+ * can identify which arguments a flag consumes and which are positional.
597
+ *
598
+ * Two classes share the table because they share the question. A pattern-first
599
+ * *matching* tool (`sed`, `grep`, `rg`) leads with a pattern and skips one or
600
+ * two positionals; an **interpreter** (`node`, `bun`, `python`, `perl`,
601
+ * `ruby`) leads with nothing and skips none, so its script can only ever
602
+ * arrive through a `script`-role flag and a script *file* stays an operand
603
+ * (#863).
506
604
  *
507
605
  * Names share a configuration object only when they share a *parser*, which is
508
606
  * narrower than being aliases: `egrep`/`fgrep` are the same binary as `grep`
509
607
  * here, and `nawk` is one-true-awk like `awk` — but `gawk` has its own config,
510
608
  * because it is the only one of the three that certainly means GNU awk and so
511
609
  * the only one whose long options certainly consume (#823).
610
+ * `node` and `bun` split for the same reason from the other direction: they
611
+ * assert identical spellings and are different binaries.
512
612
  */
513
613
  const PATTERN_FIRST_COMMANDS: ReadonlyMap<string, PatternCommandConfig> =
514
614
  new Map([
615
+ ["node", NODE_CONFIG],
616
+ ["bun", BUN_CONFIG],
617
+ ["python", PYTHON_CONFIG],
618
+ ["python3", PYTHON_CONFIG],
619
+ ["perl", PERL_CONFIG],
620
+ ["ruby", RUBY_CONFIG],
515
621
  ["sed", SED_CONFIG],
516
622
  ["awk", AWK_CONFIG],
517
623
  ["gawk", GAWK_CONFIG],
@@ -0,0 +1,60 @@
1
+ /**
2
+ * config-issue-reporter.ts — Tell the operator what is wrong with their config,
3
+ * once per issue, for as long as it is wrong.
4
+ *
5
+ * `ConfigStore` loads and answers; this decides whether the operator has
6
+ * already heard it. Splitting the two is what fixes #933: the store was primed
7
+ * at factory time, with no context to notify through, and recorded the warning
8
+ * as delivered anyway — so the identical warning at `session_start` was deduped
9
+ * away and every config issue reached the debug log alone.
10
+ *
11
+ * Delivery goes through the injected `warn` seam rather than a ctx parameter.
12
+ * That seam is `SessionLogger.warn`, which reaches the operator through
13
+ * `PermissionSession.notify` and the context the session owns — the same path
14
+ * every other session-lifecycle warning in this package takes. A reporter
15
+ * cannot be handed a context that does not exist, which is the whole point.
16
+ *
17
+ * The latch is per issue and the delivery is per report: a config with three
18
+ * detector hits produces one notification, and a later report announces only
19
+ * what is new. `reported` is *replaced* on every report rather than added to,
20
+ * so an issue the operator fixed and then reintroduced is announced again —
21
+ * which preserves the clearing behavior the store's single-string dedupe had,
22
+ * at per-issue granularity.
23
+ *
24
+ * Driven at both moments the operator is reachable: `session_start`, and every
25
+ * `before_agent_start` (the config is re-read there, so an issue created
26
+ * mid-session is caught on the next turn).
27
+ */
28
+
29
+ /** The config seam this reads (ISP): the issues current as of the last load. */
30
+ export interface ConfigIssueSource {
31
+ getConfigIssues(): readonly string[];
32
+ }
33
+
34
+ /** The log seam this writes (ISP): one operator-facing warning. */
35
+ export interface ConfigIssueWarner {
36
+ warn(message: string): void;
37
+ }
38
+
39
+ /** The seam the session-start and turn-prep handlers drive. */
40
+ export interface ConfigIssueReporting {
41
+ report(): void;
42
+ }
43
+
44
+ export class ConfigIssueReporter implements ConfigIssueReporting {
45
+ private reported: ReadonlySet<string> = new Set();
46
+
47
+ constructor(
48
+ private readonly source: ConfigIssueSource,
49
+ private readonly log: ConfigIssueWarner,
50
+ ) {}
51
+
52
+ report(): void {
53
+ const current = this.source.getConfigIssues();
54
+ const unreported = current.filter((issue) => !this.reported.has(issue));
55
+ if (unreported.length > 0) {
56
+ this.log.warn(unreported.join("\n"));
57
+ }
58
+ this.reported = new Set(current);
59
+ }
60
+ }
@@ -468,11 +468,9 @@ export function detectDeprecatedPreviewCaps(
468
468
  * make a display preference a policy event. The decision keeps its default
469
469
  * letter, and the message names the entry, the reason, and the letter kept.
470
470
  *
471
- * Where that message surfaces is the caller's problem and is currently a narrow
472
- * one: `ConfigStore` dedupes against a warning recorded by a factory-time
473
- * refresh with no ctx to notify, so an issue already on disk reaches the debug
474
- * log alone. That predates this detector and swallows its two siblings the same
475
- * way (#933).
471
+ * Where that message surfaces is the caller's problem: `ConfigIssueReporter`
472
+ * warns it at session start and on any turn whose refresh first finds it,
473
+ * latched per issue so a persisting one is not repeated (#933).
476
474
  *
477
475
  * Pure, following {@link detectPermissiveBashFallback}: it takes the merged
478
476
  * config and returns a message; the caller owns pushing it onto the issue list.
@@ -6,11 +6,9 @@ import {
6
6
  writeFileSync,
7
7
  } from "node:fs";
8
8
  import { dirname, normalize } from "node:path";
9
- import type {
10
- ExtensionCommandContext,
11
- ExtensionContext,
12
- } from "@earendil-works/pi-coding-agent";
9
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
13
10
  import type { DebugReviewLogger } from "#src/logging/session-logger";
11
+ import type { ConfigIssueSource } from "./config-issue-reporter";
14
12
  import { loadAndMergeConfigs, loadUnifiedConfig } from "./config-loader";
15
13
  import {
16
14
  getGlobalConfigPath,
@@ -40,7 +38,7 @@ export interface ConfigReader {
40
38
  * coupling between the class and test doubles.
41
39
  */
42
40
  export interface SessionConfigStore extends ConfigReader {
43
- refresh(ctx: ExtensionContext | undefined, projectTrusted: boolean): void;
41
+ refresh(cwd: string | undefined, projectTrusted: boolean): void;
44
42
  logResolvedPaths(cwd?: string): void;
45
43
  }
46
44
 
@@ -73,14 +71,17 @@ export interface ConfigStoreDeps {
73
71
  *
74
72
  * Replaces the three `(runtime, …)` config free functions
75
73
  * (`refreshExtensionConfig`, `saveExtensionConfig`, `logResolvedConfigPaths`)
76
- * with methods that privately own `config` and `lastConfigWarning`.
74
+ * with methods that privately own `config` and the issue list the last load
75
+ * produced.
77
76
  *
78
77
  * Implements {@link ConfigReader} so consumers that only read the current config
79
78
  * can depend on the narrow interface rather than the full class.
80
79
  */
81
- export class ConfigStore implements SessionConfigStore, CommandConfigStore {
80
+ export class ConfigStore
81
+ implements SessionConfigStore, CommandConfigStore, ConfigIssueSource
82
+ {
82
83
  private config: PermissionSystemExtensionConfig;
83
- private lastConfigWarning: string | null = null;
84
+ private configIssues: readonly string[] = [];
84
85
 
85
86
  constructor(private readonly deps: ConfigStoreDeps) {
86
87
  this.config = { ...DEFAULT_EXTENSION_CONFIG };
@@ -91,16 +92,39 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
91
92
  return this.config;
92
93
  }
93
94
 
95
+ /**
96
+ * What is wrong with the config as of the last {@link refresh}.
97
+ *
98
+ * Every issue `loadAndMergeConfigs` collects: a legacy-file notice, a zod
99
+ * field violation, and the cross-cutting detectors (a permissive bash
100
+ * fallback, a deprecated preview cap, a refused dialog-key binding).
101
+ *
102
+ * This store answers; `ConfigIssueReporter` decides whether the operator has
103
+ * heard it yet (#933). Not to be confused with
104
+ * `PermissionResolver.getConfigIssues(agentName?)`, which answers for the
105
+ * *policy* files rather than the extension config.
106
+ */
107
+ getConfigIssues(): readonly string[] {
108
+ return this.configIssues;
109
+ }
110
+
94
111
  /**
95
112
  * Reload merged config from disk.
96
113
  *
97
- * If `ctx` is provided, uses it to derive the cwd and sync UI status.
114
+ * `cwd` scopes the project-level lookup; omit it when no session cwd is
115
+ * known yet (the factory-time priming load).
98
116
  * When `projectTrusted` is `false`, the project scope is withheld so an
99
117
  * untrusted repository's runtime config (`yoloMode`, `permissionReviewLog`,
100
118
  * …) cannot loosen the operator's global config (#644).
119
+ *
120
+ * Takes no `ExtensionContext` on purpose: a load that holds one acquires UI
121
+ * side effects it cannot honor when there is no session yet, which is how a
122
+ * config warning came to be recorded as delivered without being shown
123
+ * (#933). `PermissionSession.refreshConfig` syncs the status bar and
124
+ * `ConfigIssueReporter` tells the operator; this reads files and answers
125
+ * questions about them.
101
126
  */
102
- refresh(ctx: ExtensionContext | undefined, projectTrusted: boolean): void {
103
- const cwd = ctx?.cwd ?? null;
127
+ refresh(cwd: string | undefined, projectTrusted: boolean): void {
104
128
  const mergeResult = loadAndMergeConfigs(
105
129
  this.deps.agentDir,
106
130
  cwd ?? "",
@@ -110,20 +134,11 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
110
134
  const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged);
111
135
  this.config = runtimeConfig;
112
136
 
113
- if (ctx?.hasUI) {
114
- syncPermissionSystemStatus(ctx, runtimeConfig);
115
- }
137
+ this.configIssues = mergeResult.issues;
116
138
 
117
139
  const warning =
118
140
  mergeResult.issues.length > 0 ? mergeResult.issues.join("\n") : undefined;
119
141
 
120
- if (warning && warning !== this.lastConfigWarning) {
121
- this.lastConfigWarning = warning;
122
- ctx?.ui.notify(warning, "warning");
123
- } else if (!warning) {
124
- this.lastConfigWarning = null;
125
- }
126
-
127
142
  this.deps.logger.debug("config.loaded", {
128
143
  warning: warning ?? null,
129
144
  debugLog: runtimeConfig.debugLog,
@@ -177,7 +192,6 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
177
192
 
178
193
  this.config = normalized;
179
194
  syncPermissionSystemStatus(ctx, normalized);
180
- this.lastConfigWarning = null;
181
195
 
182
196
  this.deps.logger.debug("config.saved", {
183
197
  debugLog: normalized.debugLog,
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ConfigIssueReporting } from "#src/config/config-issue-reporter";
2
3
  import { PERMISSION_SYSTEM_STATUS_KEY } from "#src/config/status";
3
4
  import type { DecisionSummaryWriter } from "#src/logging/decision-audit";
4
5
  import type { SessionLogger } from "#src/logging/session-logger";
@@ -36,6 +37,9 @@ export const UNTRUSTED_PROJECT_MESSAGE =
36
37
  * the ready event; `teardown` unsubscribes all session listeners and unpublishes
37
38
  * - `logger` — injected directly; replaces the former `session.logger` reach-through
38
39
  * - `audit` — per-session decision counters; its summary is written on shutdown
40
+ * - `configIssues` — reports what is wrong with the extension config, latched
41
+ * per issue; driven here and on every turn, so an issue already on disk when
42
+ * the session opens is shown rather than swallowed (#933)
39
43
  */
40
44
  export class SessionLifecycleHandler {
41
45
  constructor(
@@ -44,6 +48,7 @@ export class SessionLifecycleHandler {
44
48
  private readonly serviceLifecycle: ServiceLifecycle,
45
49
  private readonly logger: SessionLogger,
46
50
  private readonly audit: DecisionSummaryWriter,
51
+ private readonly configIssues: ConfigIssueReporting,
47
52
  ) {}
48
53
 
49
54
  handleSessionStart(
@@ -51,9 +56,16 @@ export class SessionLifecycleHandler {
51
56
  ctx: ExtensionContext,
52
57
  ): Promise<void> {
53
58
  const projectTrusted = ctx.isProjectTrusted();
54
- this.session.refreshConfig(ctx, projectTrusted);
59
+ // Reset first: it activates the session, binding the context that
60
+ // `PermissionSession.notify` — and therefore `logger.warn` — delivers
61
+ // through. A refresh before activation has no UI to report into, which is
62
+ // how a config warning present at session start went unseen (#933).
55
63
  this.session.resetForNewSession(ctx, projectTrusted);
64
+ this.session.refreshConfig(ctx, projectTrusted);
56
65
  this.session.logResolvedConfigPaths();
66
+ // The config was just re-read above, and the session is activated, so a
67
+ // warning has a UI to reach.
68
+ this.configIssues.report();
57
69
  if (!projectTrusted) {
58
70
  this.warnProjectUntrusted(ctx, "session_start");
59
71
  }
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ConfigIssueReporting } from "#src/config/config-issue-reporter";
2
3
  import type { ReadyAnnouncer } from "#src/service/service-lifecycle";
3
4
 
4
5
  /** The session surface the turn-prep routine drives. */
@@ -27,6 +28,9 @@ export interface TurnPreparation {
27
28
  * - `warmParser` — warms the tree-sitter parser so the synchronous advisory
28
29
  * bash path can decompose at gate parity; `before_agent_start` precedes any
29
30
  * tool call, so triggering it here closes the pre-warm window (#309)
31
+ * - `configIssues` — reports what the config refresh found, latched per issue,
32
+ * so an issue the operator created mid-session is shown on the next turn
33
+ * without an unchanged one being repeated every turn (#933)
30
34
  * - `readyAnnouncer` — re-announces `permissions:ready` once per session
31
35
  * (ADR 0012 decision 3); `before_agent_start` runs after every extension's
32
36
  * `session_start` and before any ask, so a consumer that registers from the
@@ -37,6 +41,7 @@ export class SessionTurnPrep implements TurnPreparation {
37
41
  private readonly session: TurnPrepSession,
38
42
  private readonly warmParser: () => void,
39
43
  private readonly readyAnnouncer: ReadyAnnouncer,
44
+ private readonly configIssues: ConfigIssueReporting,
40
45
  ) {}
41
46
 
42
47
  prepare(ctx: ExtensionContext): void {
@@ -50,6 +55,10 @@ export class SessionTurnPrep implements TurnPreparation {
50
55
  // right before agent start after session_start withheld it (#644). The
51
56
  // session_start handler already warned; do not re-warn on every start.
52
57
  this.session.refreshConfig(ctx, ctx.isProjectTrusted());
58
+ // Say what that refresh found, before the announce below hands this node
59
+ // to a consumer: the operator hears about a config they just broke on the
60
+ // next turn, and hears an unchanged issue only once.
61
+ this.configIssues.report();
53
62
  // Announce last: the node is up to date for the turn, so a consumer that
54
63
  // resolves the service in its ready handler queries current policy. The
55
64
  // once-per-session guard lives in the announcer, not here.
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  import { SubagentDetection } from "#src/authority/subagent-detection";
34
34
  import { subscribeSubagentLifecycle } from "#src/authority/subagent-lifecycle-events";
35
35
  import { getSubagentSessionRegistry } from "#src/authority/subagent-registry";
36
+ import { ConfigIssueReporter } from "#src/config/config-issue-reporter";
36
37
  import { registerPermissionSystemCommand } from "#src/config/config-modal";
37
38
  import { getGlobalConfigPath } from "#src/config/config-paths";
38
39
  import { ConfigStore } from "#src/config/config-store";
@@ -236,8 +237,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
236
237
  // refresh() must run after `session` is assigned: a debug-write IO failure
237
238
  // triggers the logger's notify sink — `session.notify(m)` — which no-ops
238
239
  // on the null context but requires `session` to be bound.
239
- // No ctx/trust decision exists at factory init, so withhold the project
240
- // scope (fail closed); session_start reloads with the real trust decision.
240
+ // No cwd or trust decision exists at factory init, so there is no project
241
+ // scope to withhold from (fail closed); session_start reloads with the real
242
+ // cwd and trust decision. This load reports nothing to the operator: it
243
+ // cannot, and it used to consume the warning by pretending it had (#933).
241
244
  configStore.refresh(undefined, false);
242
245
 
243
246
  const configPath = getGlobalConfigPath(agentDir);
@@ -305,12 +308,19 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
305
308
  };
306
309
 
307
310
  const audit = new DecisionAudit();
311
+ // Reads the store's issue list and tells the operator what is new, through
312
+ // the logger's warn sink rather than a ctx parameter — which is what the
313
+ // factory-time priming refresh lacked, so every config issue already on disk
314
+ // was recorded as delivered and never shown (#933). Driven at session_start
315
+ // and on every turn; the latch keeps an unchanged issue quiet.
316
+ const configIssueReporter = new ConfigIssueReporter(configStore, logger);
308
317
  const lifecycle = new SessionLifecycleHandler(
309
318
  session,
310
319
  resolver,
311
320
  serviceLifecycle,
312
321
  logger,
313
322
  audit,
323
+ configIssueReporter,
314
324
  );
315
325
  const turnPrep = new SessionTurnPrep(
316
326
  session,
@@ -318,6 +328,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
318
328
  void warmBashParser();
319
329
  },
320
330
  serviceLifecycle,
331
+ configIssueReporter,
321
332
  );
322
333
  const agentPrep = new AgentPrepHandler(
323
334
  turnPrep,
@@ -5,6 +5,7 @@ import type { ShellToolsConfig } from "#src/config/config-schema";
5
5
  import type { SessionConfigStore } from "#src/config/config-store";
6
6
  import type { PermissionSystemExtensionConfig } from "#src/config/extension-config";
7
7
  import type { ExtensionPaths } from "#src/config/extension-paths";
8
+ import { syncPermissionSystemStatus } from "#src/config/status";
8
9
  import type { SkillPromptEntry } from "#src/exposure/skill-prompt-sanitizer";
9
10
  import {
10
11
  ToolSurfaceBaseline,
@@ -202,15 +203,25 @@ export class PermissionSession implements ToolCallGateInputs {
202
203
  // ── Config ─────────────────────────────────────────────────────────────
203
204
 
204
205
  /**
205
- * Reload merged config from disk; optionally update the stored runtime
206
- * context. When `projectTrusted` is `false`, the project scope is withheld
207
- * so an untrusted project's runtime config is not merged (#644).
206
+ * Reload merged config from disk, then bring the status bar in step with it.
207
+ *
208
+ * When `projectTrusted` is `false`, the project scope is withheld so an
209
+ * untrusted project's runtime config is not merged (#644).
210
+ *
211
+ * The status sync lives here rather than in `ConfigStore` because it is a UI
212
+ * side effect of the session, keyed on the session's context: the store
213
+ * loads and answers, and needs no ctx to do it (#933). Both drivers
214
+ * (`session_start` and every `before_agent_start`) call this one method, so
215
+ * the sync has a single home rather than one copy per handler.
208
216
  */
209
217
  refreshConfig(
210
218
  ctx: ExtensionContext | undefined,
211
219
  projectTrusted: boolean,
212
220
  ): void {
213
- this.configStore.refresh(ctx, projectTrusted);
221
+ this.configStore.refresh(ctx?.cwd, projectTrusted);
222
+ if (ctx?.hasUI) {
223
+ syncPermissionSystemStatus(ctx, this.configStore.current());
224
+ }
214
225
  }
215
226
 
216
227
  /** Write the resolved config path set to the review and debug logs. */