@gotgenes/pi-permission-system 33.0.2 → 33.0.4

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,30 @@ 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.4](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v33.0.3...pi-permission-system-v33.0.4) (2026-09-20)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **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)
14
+
15
+ ### Documentation
16
+
17
+ * **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)
18
+ * **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)
19
+
20
+ ## [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)
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * **pi-permission-system:** project the operands of a command hosted in a quoted argument ([3da8a63](https://github.com/gotgenes/pi-packages/commit/3da8a63b0f4f4f234c1dfdf2196a83f4f62940e8)), closes [#945](https://github.com/gotgenes/pi-packages/issues/945)
26
+ * **pi-permission-system:** project the operands of a command hosted in a generic command's quoted argument ([9eeece0](https://github.com/gotgenes/pi-packages/commit/9eeece05789fab4fe44b71f98cd3179e838a5def)), closes [#945](https://github.com/gotgenes/pi-packages/issues/945)
27
+
28
+ ### Documentation
29
+
30
+ * **pi-permission-system:** record the widened hosted-argument projection ([c9b277a](https://github.com/gotgenes/pi-packages/commit/c9b277a495dad8fde7fe4d18536aae9ee384fee3)), closes [#945](https://github.com/gotgenes/pi-packages/issues/945)
31
+
8
32
  ## [33.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v33.0.1...pi-permission-system-v33.0.2) (2026-09-19)
9
33
 
10
34
 
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "33.0.2",
3
+ "version": "33.0.4",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -129,7 +129,9 @@ export function collectRedirectTokens(node: TSNode): PathToken[] {
129
129
  * execution contexts, reading none of the host subtree's own text.
130
130
  *
131
131
  * This is what lets a heredoc body contribute its substitution's operands while
132
- * its prose stays out of the path surface entirely.
132
+ * its prose stays out of the path surface entirely, and an argument node
133
+ * contribute its quoted substitution's operands while its own text is judged
134
+ * by whatever role its walker gave it (#945).
133
135
  *
134
136
  * `node` may be a context outright (`> $(cmd)`) or merely contain one
135
137
  * (`> ${DIR}/$(cmd)`), so the traversal is the root-inclusive
@@ -617,6 +619,15 @@ function collectPatternCommandTokens(
617
619
  const isArgNode = ARG_NODE_TYPES.has(child.type);
618
620
  const text = resolveNodeText(child);
619
621
 
622
+ // An argument is read for its text below, and its text alone — but a
623
+ // quoted substitution sitting there really runs, and its own operands are
624
+ // candidates wherever it sits (ADR 0009's positional invariance). The
625
+ // unquoted spelling reaches the nested command through the `!isArgNode`
626
+ // recursions below; the quoted one parses as a `string` and would
627
+ // otherwise stop here, whichever role the argument turns out to have
628
+ // (#945).
629
+ if (isArgNode) tokens.push(...collectHostedExecutionTokens(child));
630
+
620
631
  // Handle the argument a previous flag consumed. The consumption discharges
621
632
  // on whatever node type follows, not only on an ARG_NODE_TYPES one: a bare
622
633
  // number (`-A 3`), an expansion (`-A $N`), and a substitution
@@ -772,6 +783,13 @@ function collectGenericCommandTokens(
772
783
  continue;
773
784
  }
774
785
 
786
+ // Read for its text below — but a quoted substitution among these
787
+ // arguments really runs, and its own operands are candidates wherever it
788
+ // sits (ADR 0009's positional invariance, #945). The unquoted spelling
789
+ // reaches the nested command through the trailing recursion.
790
+ if (ARG_NODE_TYPES.has(child.type))
791
+ tokens.push(...collectHostedExecutionTokens(child));
792
+
775
793
  // If there was no explicit command_name node, the first word-like
776
794
  // child is the command name itself — skip it.
777
795
  if (!seenCommandName && ARG_NODE_TYPES.has(child.type)) {
@@ -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. */