15 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 363 | Add `PermissionSession.notify()` and dissolve the `index.ts` forward-reference cycle |
Add PermissionSession.notify() and dissolve the index.ts forward-reference cycle
Problem Statement
The composition root (src/index.ts) papers over a true construction cycle with a null-init cast and a mutable holder.
The logger needs late-bound config-reading and UI-notify capability, but it is constructed before the ConfigStore and PermissionSession it depends on.
Today that is bridged two ways:
let configStore = null as unknown as ConfigStore— the only productionas unknown ascast in the package, used solely so the logger'sgetConfig: () => configStore.current()thunk compiles.let sessionNotify: PermissionSession | null = null, assignedsessionNotify = sessionafter the session is built, with the notify sink reaching through it:sessionNotify?.getRuntimeContext()?.ui.notify(message, "warning").
Two smells ride together: the as unknown as cast and the getRuntimeContext()?.ui.notify(...) Law-of-Demeter reach-through.
PermissionSession owns the context, so it should expose the notify behavior (Tell-Don't-Ask) rather than letting a closure reach through it to .ui.
This is Phase 5 Step 2 (Track A) from docs/architecture/architecture.md.
The change is behavior-preserving.
Goals
- Add a
notify(message: string)method toPermissionSessionthat tells the owned context to surface a warning (Tell-Don't-Ask), no-op when no UI context is active. - Wire the logger's notify sink as
(m) => session.notify(m), replacing thegetRuntimeContext()?.ui.notifyreach-through. - Remove the
let configStore = null as unknown as ConfigStorecast and thelet sessionNotifyholder, ordering construction so the logger'sgetConfig/notifysinks resolve via lazy thunks over forward-declared bindings — no cast, no null-init holder. - Outcome: production
as unknown ascasts drop 3 → 2 (the two remaining are JSON-serialization casts inconfig-store.ts);index.tshas nonull-init holders.
This change is not breaking: notify behavior is identical (a warning is surfaced when a UI context is active, no-op otherwise); no public API, config, default, or output shape changes.
Non-Goals
- Dropping the relay-only
loggerfield fromPermissionSessionor injecting the logger directly into the lifecycle handler / reporter — that is Phase 5 Step 3 (#364), which shares edits topermission-session.tsandindex.tsand lands after this step. - Touching the
SessionLoggerinterface, thePermissionSessionLoggerclass, or itsnotifydep signature — the sink stays(message: string) => void; only the value passed at the composition root changes. - The anemic cache-key accessors /
CacheKeyGatework (#365, Track B) — differentpermission-session.tsmembers. - Any change to
ConfigStore.refresh()semantics — only its call-site ordering in the factory moves.
Background
Relevant modules:
src/index.ts— the extension factory / composition root. Constructs the logger first, thenconfigStore,forwarder,prompter, callsconfigStore.refresh(), then buildsgatewayandsession, then assignssessionNotify = session. The logger's deps close overconfigStore(viagetConfig) andsessionNotify(vianotify), both of which are unavailable at logger-construction time — hence the cast and the holder.src/permission-session.ts—PermissionSessionowns theprivate context: ExtensionContext | nullfield and already exposesgetRuntimeContext()plus context-tapping methods (reload,logResolvedConfigPathsreadthis.context?.cwd). Addingnotifyfollows the samethis.context?.…pattern.src/session-logger.ts—PermissionSessionLogger(the class shipped by #362) takesnotify: (message: string) => voidinSessionLoggerDepsand routes both IO-failure warnings (deduped viareportOnce) and explicitwarn()calls through it. The sink signature is unchanged here.src/config-store.ts—ConfigStore.refresh()surfaces config-merge warnings through the passedctx?.ui.notify(...)directly (a no-op at factory-init, where it is called with noctx). Separately,refresh()callsthis.deps.logger.debug("config.loaded", …); if that debug write fails IO, the logger'sreportOncepath invokes the injected notify sink — the one path by which the sink can fire during construction.
Constraints from AGENTS.md / the package skill:
- "Changes to publication timing or teardown order should go through
PermissionServiceLifecycle, notindex.ts" — not relevant here; this change touches only collaborator construction ordering, not service publication/teardown. - Biome bans
x!(noNonNullAssertion); alet configStore: ConfigStore | undefined+configStore!.current()workaround is therefore not viable — the forward-declared annotatedlet(no initializer) is the clean path. - Forward-declared
let x: T;(no initializer, assigned once later) is established codebase precedent (e.g.let state: SessionState | undefined;inpi-autoformat/src/extension.ts);prefer-const/ biomeuseConstdoes not flag it because aconstcannot be declared without an initializer, so the rule cannot suggest the conversion.
Design Overview
The notify method on PermissionSession
PermissionSession owns the runtime context, so it owns the behavior of surfacing a warning through it.
The method taps the private context field directly (consistent with reload / logResolvedConfigPaths), short-circuiting to a no-op when no UI context is active — the same best-effort semantics the old sessionNotify?.getRuntimeContext()?.ui.notify(...) chain had:
// ── UI notifications ────────────────────────────────────────────────────
/** Surface a warning message to the user via the active UI context, if any. */
notify(message: string): void {
this.context?.ui.notify(message, "warning");
}
This replaces a four-link reach-through (sessionNotify → getRuntimeContext() → ?.ui → .notify) with a single tell to the context-owning session.
Construction order at the composition root
The cycle is genuine and bidirectional in two pairs:
- logger needs
configStore(lazily, viagetConfig);configStoreneedslogger(eagerly, at construction). - logger needs
session(lazily, vianotify);sessionneedslogger(eagerly, at construction).
Lazy thunks break both cycles: getConfig / notify are invoked only at log-write / warn time, never during construction.
The forward references therefore need only be in scope as let bindings — no cast, no null-init holder:
let configStore: ConfigStore;
let session: PermissionSession;
const logger = new PermissionSessionLogger({
globalLogsDir: paths.globalLogsDir,
getConfig: () => configStore.current(),
notify: (message) => session.notify(message),
});
configStore = new ConfigStore({ agentDir, policyPaths: permissionManager, logger });
// ... forwarder, prompter ...
const gateway = new PromptingGateway({ ... });
session = new PermissionSession(
paths,
logger,
new ForwardingManager(paths.subagentSessionsDir, forwarder, subagentRegistry),
permissionManager,
sessionRules,
configStore,
gateway,
);
configStore.refresh(); // moved: now runs after `session` is assigned
Why configStore.refresh() must move after session
configStore.refresh() calls this.deps.logger.debug("config.loaded", …).
If that debug write fails IO (debug logging enabled + filesystem error), the logger's reportOnce path fires the notify sink — (m) => session.notify(m).
With the old sessionNotify?. guard this was a safe no-op while the session was unbuilt; with a direct session.notify(m), calling it while session is still undefined would throw Cannot read properties of undefined.
Moving refresh() to after the session assignment guarantees session is bound before any sink can fire.
session.notify then internally no-ops because this.context is still null at factory-init (no activate() has run yet) — preserving today's behavior exactly.
Reordering is safe: PermissionPrompter, PromptingGateway, and PermissionSession constructors only store references; nothing between the old and new refresh() positions reads merged config eagerly (handlers and the command read config at event time).
Edge cases (all preserved)
- No UI context yet (factory-init, pre-
activate):this.context?short-circuits — no-op, as today. - UI context active (mid-session):
ctx.ui.notify(message, "warning")— identical to the old chain's terminal call. - Config-merge warnings in
refresh()still flow throughctx?.ui.notify(...)directly (unchanged); only the logger sink routes throughsession.notify.
Module-Level Changes
src/permission-session.ts- Add the
notify(message: string): voidmethod (tapsthis.context?.ui.notify(message, "warning")). No new constructor field; no interface change.
- Add the
src/index.ts- Replace
let configStore = null as unknown as ConfigStorewithlet configStore: ConfigStore;(annotated forward declaration, no initializer). - Remove
let sessionNotify: PermissionSession | null = null;and thesessionNotify = session;assignment; addlet session: PermissionSession;forward declaration and assign it in place (session = new PermissionSession(...)). - Change the logger's notify sink from
(message) => sessionNotify?.getRuntimeContext()?.ui.notify(message, "warning")to(message) => session.notify(message). - Move the
configStore.refresh()call to immediately after thesession = new PermissionSession(...)assignment. - Remove the now-stale forward-reference comments.
- Replace
docs/architecture/architecture.md- Update the
permission-session.tslayout line (line ~500) to note the newnotifyUI-tell over the owned context. - Do not edit the Phase 5 metrics table or roadmap-step prose — they are phase-start snapshots, not live counts (the #336 / #362 convention); the
✓ completeroadmap mark is appended at ship time by/ship-issue, not during this change.
- Update the
Grep confirms sessionNotify appears only in src/index.ts; the null as unknown as ConfigStore cast appears only in src/index.ts.
No test references sessionNotify.
The package skill does not reference either, so no skill edit is required.
Test Impact Analysis
- New unit tests enabled: the
PermissionSession.notify()method is directly unit-testable in isolation — previously the notify behavior lived in anindex.tsclosure reachable only through the composition root. New cases (intest/permission-session.test.ts, usingmakeRealSession+makeCtx, whoseui.notifyis already avi.fn()):- after
activate(ctx),session.notify(msg)callsctx.ui.notify(msg, "warning"); - before activation (or after
deactivate()),session.notify(msg)is a no-op and does not throw.
- after
- Redundant tests: none.
No existing test covered the
index.tsnotify closure directly, so nothing is superseded. - Tests that must stay as-is: the existing
composition-root.test.tsfactory-construction tests (they exercise the real wiring and back-stop the reorder) and thesession-logger.test.tsnotify-sink tests (the sink signature is unchanged).
TDD Order
The notify method and the index.ts rewiring land in one cycle: between adding the method and wiring its sole production caller, notify would be a public class member with no production caller, which pnpm fallow dead-code can flag as unused-class-member.
Folding both keeps a production caller present in the same commit.
The rewiring is behavior-preserving and is covered at the type level by pnpm run check and at runtime by the existing composition-root.test.ts factory smoke tests, so no new composition-root test is required.
- Add
PermissionSession.notify()and dissolve theindex.tsforward-reference cycle —refactor:- Red: in
test/permission-session.test.ts, add adescribe("notify", …)block asserting (a) the message is forwarded toctx.ui.notify(message, "warning")afteractivate, and (b) it is a no-op (no throw) before activation / afterdeactivate. Fails to compile becausenotifydoes not exist. - Green:
- Add the
notify(message: string): voidmethod toPermissionSession. - In
index.ts: replace the cast withlet configStore: ConfigStore;, replace thesessionNotifyholder withlet session: PermissionSession;, change the notify sink to(m) => session.notify(m), assignsession = new PermissionSession(...)in place, and moveconfigStore.refresh()to after that assignment; delete the stale forward-reference comments. - Update the
permission-session.tslayout line indocs/architecture/architecture.md.
- Add the
- Verify:
pnpm run check, the full test suite (pnpm -r run testor the package filter), andpnpm fallow dead-code(confirmnotifyhas a production caller, no orphaned holder, and productionas unknown ascount dropped to 2). - Commit:
refactor: add PermissionSession.notify() and dissolve index.ts forward-reference cycle.
- Red: in
Risks and Mitigations
- Risk: the notify sink fires during
configStore.refresh()whilesessionis stillundefined, throwing. Mitigation: moveconfigStore.refresh()to after thesessionassignment (see Design Overview);session.notifythen no-ops on the null context. The reorder is behavior-equivalent because no constructor between the old and new positions reads merged config eagerly. - Risk: a linter (
prefer-const/ biomeuseConst) flags the forward-declaredlet configStore/let session. Mitigation: the rule cannot suggestconstfor aletdeclared without an initializer (assigned in a later statement), so it does not fire; established codebase precedent confirms this (pi-autoformat/src/extension.ts).pnpm run checkis the backstop. - Risk: a forward-declared
letreferenced in a closure trips a "used before assigned" (TS2454) error. Mitigation: TypeScript exempts closure captures from definite-assignment analysis (it cannot know when the closure runs); all synchronous uses of both bindings occur after their assignment.pnpm run checkconfirms. - Risk: a hidden consumer of
sessionNotifyor the cast breaks. Mitigation: grep-confirmed both symbols are confined tosrc/index.ts; the single-commit rewiring keeps every importer green;pnpm fallow dead-codeis the backstop.
Open Questions
- None.
The construction-ordering approach (lazy thunks over forward-declared
letbindings + reorderedrefresh()) follows directly from the genuine cycle and is the minimal change that removes both the cast and the holder. Dropping the relay-onlyloggerfield is deferred to #364 as planned.