pi-fluency 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,18 +8,47 @@ import type {
8
8
  } from "@earendil-works/pi-coding-agent";
9
9
  import { Key } from "@earendil-works/pi-tui";
10
10
  import { AnalyzerConfigurationError, ModelAnalyzer, type Analyzer } from "./analyzer.js";
11
- import { computeFluencyAnalytics } from "./analytics.js";
11
+ import { computeFluencyAnalytics, resolvePracticeTargets, selectPracticeAnalysisContext } from "./analytics.js";
12
12
  import { collectPrompt } from "./collector.js";
13
+ import {
14
+ showCoachingOverlay,
15
+ type CoachingCheckResult,
16
+ type CoachingOverlayDecision,
17
+ type CoachingSnoozeDecision,
18
+ type CoachingSnoozeHandler,
19
+ } from "./coaching-overlay.js";
20
+ import {
21
+ analysisReuseAction,
22
+ analyzerResultFingerprint,
23
+ isCoachingEligible,
24
+ revalidateCoachingPolicy,
25
+ selectedCoachingMistakes,
26
+ } from "./coaching.js";
13
27
  import { showFluencyOverlay, type FluencyView } from "./overlay.js";
28
+ import { PracticeSessionSnooze } from "./practice-settings.js";
14
29
  import { sanitizeTerminalLabel } from "./sanitize.js";
15
30
  import { runSetup } from "./setup.js";
16
31
  import { formatStatus, type StatusErrorReason, type StatusState } from "./status.js";
17
- import { FluencyStore } from "./store.js";
18
- import type { FluencySettings } from "./types.js";
19
- import { FluencyWorker } from "./worker.js";
32
+ import { FluencyStore, type AnalysisCommitFence } from "./store.js";
33
+ import type { FluencySettings, PracticeSettings } from "./types.js";
34
+ import { FluencyWorker, type ForegroundAnalysisOutcome } from "./worker.js";
20
35
 
21
36
  const STATUS_KEY = "pi-fluency";
22
- const USAGE = "Usage: /fluency [pause|resume|status|model|clear|stats]";
37
+ const USAGE = "Usage: /fluency [pause|resume|status|model|clear|stats|practice [on|off|resume|reset]]";
38
+ const PRACTICE_DISCLOSURE = "Before main submission, full sanitized draft goes to configured Fluency model and may be analyzed even if you later choose not to send it.";
39
+ const PRACTICE_CHECK_TIMEOUT_MS = 30_000;
40
+ const PRACTICE_CHECK_ABORT_GRACE_MS = 100;
41
+
42
+ function practiceFailureMessage(kind: Exclude<ForegroundAnalysisOutcome["kind"], "success">): string {
43
+ switch (kind) {
44
+ case "busy": return "Sent without practice check — analyzer was busy for 30 seconds.";
45
+ case "timeout": return "Sent without practice check — analyzer timed out after 30 seconds.";
46
+ case "error": return "Sent without practice check — analyzer failed.";
47
+ case "shutdown": return "Sent without practice check — analyzer stopped during reload or shutdown.";
48
+ case "quarantined": return "Sent without practice check — analyzer unavailable; restart Pi to restore practice.";
49
+ case "cancelled": return "Sent without practice check — practice settings changed.";
50
+ }
51
+ }
23
52
 
24
53
  export interface OpenInboxOptions {
25
54
  signal: AbortSignal;
@@ -35,11 +64,20 @@ export type OpenInbox = (
35
64
  options: OpenInboxOptions,
36
65
  ) => Promise<void> | void;
37
66
 
67
+ export type ShowCoaching = (
68
+ ctx: ExtensionContext,
69
+ check: Promise<CoachingCheckResult>,
70
+ signal?: AbortSignal,
71
+ saveSnooze?: CoachingSnoozeHandler,
72
+ ) => Promise<CoachingOverlayDecision>;
73
+
38
74
  export interface ExtensionDependencies {
39
75
  rootDir?: string;
40
- analyzerFactory?: (ctx: ExtensionContext, store: FluencyStore) => Analyzer;
76
+ analyzerFactory?: (ctx: ExtensionContext, store: FluencyStore, settings?: FluencySettings) => Analyzer;
41
77
  now?: () => number;
78
+ openStore?: (rootDir: string) => Promise<FluencyStore>;
42
79
  openInbox?: OpenInbox;
80
+ showCoaching?: ShowCoaching;
43
81
  }
44
82
 
45
83
  interface ResolvedDependencies extends ExtensionDependencies {
@@ -63,6 +101,27 @@ function hasValidConfiguration(settings: FluencySettings, ctx: ExtensionContext)
63
101
  return ctx.modelRegistry.find(settings.provider!, settings.modelId!) !== undefined;
64
102
  }
65
103
 
104
+ function fenceFromPolicy(policy: { settings: FluencySettings; historyGeneration: string }): AnalysisCommitFence {
105
+ const settings = policy.settings;
106
+ return {
107
+ historyGeneration: policy.historyGeneration,
108
+ enabled: settings.enabled,
109
+ minimumConfidence: settings.minimumConfidence,
110
+ ...(settings.consentedAt === undefined ? {} : { consentedAt: settings.consentedAt }),
111
+ ...(settings.provider === undefined ? {} : { provider: settings.provider }),
112
+ ...(settings.modelId === undefined ? {} : { modelId: settings.modelId }),
113
+ };
114
+ }
115
+
116
+ function sameAnalysisFence(left: AnalysisCommitFence, right: AnalysisCommitFence): boolean {
117
+ return left.historyGeneration === right.historyGeneration
118
+ && left.enabled === right.enabled
119
+ && left.consentedAt === right.consentedAt
120
+ && left.provider === right.provider
121
+ && left.modelId === right.modelId
122
+ && left.minimumConfidence === right.minimumConfidence;
123
+ }
124
+
66
125
  function registerHandlers(pi: ExtensionAPI, dependencies: ResolvedDependencies): void {
67
126
  let storeRef: FluencyStore | undefined;
68
127
  let storePromise: Promise<FluencyStore> | undefined;
@@ -75,6 +134,26 @@ function registerHandlers(pi: ExtensionAPI, dependencies: ResolvedDependencies):
75
134
  const notifiedErrors = new Set<string>();
76
135
  const inputSessionId = randomUUID();
77
136
  let inputSequence = 0;
137
+ const practiceSessionSnooze = new PracticeSessionSnooze();
138
+ const coachingControllers = new Set<AbortController>();
139
+ const scheduledCommits = new Set<Promise<void>>();
140
+
141
+ const sessionFile = (ctx: ExtensionContext): string | undefined =>
142
+ ctx.sessionManager?.getSessionFile?.();
143
+ const sessionEntries = (ctx: ExtensionContext) => ctx.sessionManager?.getEntries?.() ?? [];
144
+ const isSessionPracticeSnoozed = (
145
+ ctx: ExtensionContext,
146
+ store: FluencyStore,
147
+ practice: PracticeSettings = store.getPracticeSettings(),
148
+ ): boolean => practiceSessionSnooze.restore(sessionEntries(ctx), sessionFile(ctx), practice.epoch);
149
+ const resumeSessionPractice = (ctx: ExtensionContext, store: FluencyStore): void => {
150
+ const practice = store.getPracticeSettings();
151
+ practiceSessionSnooze.resume(
152
+ sessionFile(ctx),
153
+ practice.epoch,
154
+ (customType, data) => pi.appendEntry(customType, data),
155
+ );
156
+ };
78
157
 
79
158
  const publishStatus = (ctx: ExtensionContext, state: StatusState): void => {
80
159
  const text = formatStatus(state);
@@ -139,13 +218,36 @@ function registerHandlers(pi: ExtensionAPI, dependencies: ResolvedDependencies):
139
218
  };
140
219
 
141
220
  const getStore = async (): Promise<FluencyStore> => {
142
- storePromise ??= FluencyStore.open(dependencies.rootDir).then((store) => {
143
- storeRef = store;
144
- return store;
145
- });
221
+ storePromise ??= (dependencies.openStore?.(dependencies.rootDir) ?? FluencyStore.open(dependencies.rootDir))
222
+ .then((store) => {
223
+ if (!shuttingDown) storeRef = store;
224
+ return store;
225
+ });
146
226
  return storePromise;
147
227
  };
148
228
 
229
+ const getStoreBefore = async (deadline: number): Promise<FluencyStore> => {
230
+ const acquisition = getStore().then(
231
+ (store) => ({ kind: "store" as const, store }),
232
+ () => ({ kind: "error" as const }),
233
+ );
234
+ const remaining = deadline - Date.now();
235
+ if (remaining <= 0) throw new Error("Store acquisition deadline exceeded");
236
+ let timer: ReturnType<typeof setTimeout> | undefined;
237
+ try {
238
+ const result = await Promise.race([
239
+ acquisition,
240
+ new Promise<{ kind: "deadline" }>((resolve) => {
241
+ timer = setTimeout(() => resolve({ kind: "deadline" }), remaining);
242
+ }),
243
+ ]);
244
+ if (result.kind === "store") return result.store;
245
+ throw new Error(result.kind === "error" ? "Store acquisition failed" : "Store acquisition deadline exceeded");
246
+ } finally {
247
+ if (timer !== undefined) clearTimeout(timer);
248
+ }
249
+ };
250
+
149
251
  const analyzerErrorReason = (error: unknown): StatusErrorReason => {
150
252
  if (!(error instanceof AnalyzerConfigurationError)) return "analyze";
151
253
  return /auth|api[- ]?key|credential|token/i.test(error.message) ? "auth" : "model";
@@ -161,9 +263,12 @@ function registerHandlers(pi: ExtensionAPI, dependencies: ResolvedDependencies):
161
263
  }
162
264
  };
163
265
 
164
- const createAnalyzer = (ctx: ExtensionContext, store: FluencyStore): Analyzer => {
165
- if (dependencies.analyzerFactory) return dependencies.analyzerFactory(ctx, store);
166
- const settings = store.getSettings();
266
+ const createAnalyzer = (
267
+ ctx: ExtensionContext,
268
+ store: FluencyStore,
269
+ settings: FluencySettings = store.getSettings(),
270
+ ): Analyzer => {
271
+ if (dependencies.analyzerFactory) return dependencies.analyzerFactory(ctx, store, settings);
167
272
  const model = settings.provider && settings.modelId
168
273
  ? ctx.modelRegistry.find(settings.provider, settings.modelId)
169
274
  : undefined;
@@ -179,11 +284,24 @@ function registerHandlers(pi: ExtensionAPI, dependencies: ResolvedDependencies):
179
284
  ctxRef = ctx;
180
285
  workerRef ??= new FluencyWorker({
181
286
  analyzer: createAnalyzer(ctx, store),
287
+ getAnalyzerConfiguration: async (job) => {
288
+ if (job.fence === undefined) return undefined;
289
+ const currentCtx = ctxRef ?? ctx;
290
+ const fresh = await store.getFreshPolicySnapshot(Date.now() + 1_000);
291
+ const freshFence = fenceFromPolicy(fresh);
292
+ if (!sameAnalysisFence(job.fence, freshFence) || !hasConfiguredIdentity(fresh.settings)) return undefined;
293
+ return {
294
+ fingerprint: analyzerResultFingerprint(fresh.settings),
295
+ analyzer: createAnalyzer(currentCtx, store, fresh.settings),
296
+ };
297
+ },
182
298
  isIdle: () => ctxRef?.isIdle() ?? false,
183
299
  getPatterns: () => store.listKnownPatterns(),
184
- onResult: async (prompt, result) => {
300
+ onResult: async (prompt, result, fence) => {
301
+ if (fence === undefined) return;
185
302
  try {
186
- await store.appendAnalysis(prompt, result);
303
+ const committed = await store.conditionalAppendAnalysis(fence, prompt, result);
304
+ if (committed !== "committed") return;
187
305
  } catch (error) {
188
306
  if (ctxRef) setError(ctxRef, "store", error);
189
307
  return;
@@ -242,6 +360,10 @@ function registerHandlers(pi: ExtensionAPI, dependencies: ResolvedDependencies):
242
360
  onMutationError,
243
361
  initialView,
244
362
  dependencies.now,
363
+ {
364
+ sessionSnoozed: () => isSessionPracticeSnoozed(ctx, store),
365
+ resumeSession: () => resumeSessionPractice(ctx, store),
366
+ },
245
367
  ),
246
368
  );
247
369
  overlayOpen = current;
@@ -288,6 +410,44 @@ function registerHandlers(pi: ExtensionAPI, dependencies: ResolvedDependencies):
288
410
  await openInbox(ctx, store, "stats");
289
411
  return;
290
412
  }
413
+ if (action === "practice") {
414
+ await openInbox(ctx, store, "stats");
415
+ return;
416
+ }
417
+ if (action === "practice on") {
418
+ const practice = store.getPracticeSettings();
419
+ if (practice.consentedAt === undefined) {
420
+ const confirmed = await ctx.ui.confirm("Enable Pi Fluency practice?", PRACTICE_DISCLOSURE);
421
+ if (!confirmed) {
422
+ ctx.ui.notify("Pi Fluency practice unchanged", "info");
423
+ return;
424
+ }
425
+ await store.activatePractice(dependencies.now());
426
+ } else {
427
+ await store.setPracticeEnabled(true);
428
+ }
429
+ ctx.ui.notify("Pi Fluency practice enabled", "info");
430
+ return;
431
+ }
432
+ if (action === "practice off") {
433
+ await store.setPracticeEnabled(false);
434
+ ctx.ui.notify("Pi Fluency practice disabled", "info");
435
+ return;
436
+ }
437
+ if (action === "practice resume") {
438
+ // Mutation rereads sidecar under global lock, so stale process cache cannot miss durable snooze.
439
+ await store.resumePractice();
440
+ if (isSessionPracticeSnoozed(ctx, store)) resumeSessionPractice(ctx, store);
441
+ ctx.ui.notify("Pi Fluency practice resumed now", "info");
442
+ return;
443
+ }
444
+ if (action === "practice reset") {
445
+ if (await ctx.ui.confirm("Reset Pi Fluency practice?", "This clears practice targets, consent, mode, and snoozes. Fluency history stays unchanged.")) {
446
+ await store.resetPractice();
447
+ ctx.ui.notify("Pi Fluency practice reset", "info");
448
+ }
449
+ return;
450
+ }
291
451
  if (action === "pause") {
292
452
  await store.updateSettings({ enabled: false });
293
453
  await workerRef?.shutdown();
@@ -320,9 +480,15 @@ function registerHandlers(pi: ExtensionAPI, dependencies: ResolvedDependencies):
320
480
  const state = active
321
481
  ? "enabled"
322
482
  : settings.enabled ? "inactive (configuration invalid)" : "paused";
483
+ const practice = store.getPracticeSettings();
484
+ const globalSnoozed = (practice.snoozedUntil ?? 0) > dependencies.now();
485
+ const sessionSnoozed = isSessionPracticeSnoozed(ctx, store);
486
+ const practiceSnooze = globalSnoozed && sessionSnoozed
487
+ ? "session+5-hour"
488
+ : sessionSnoozed ? "session" : globalSnoozed ? "5-hour" : "none";
323
489
  if (!active) clearStatus(ctx);
324
490
  ctx.ui.notify(
325
- `Pi Fluency: ${state}; model=${model}; queued=${snapshot.queued}; dropped=${snapshot.dropped}; warnings=${store.getWarnings().length}`,
491
+ `Pi Fluency: ${state}; model=${model}; queued=${snapshot.queued}; dropped=${snapshot.dropped}; warnings=${store.getWarnings().length}; practice=${practice.enabled ? "on" : "off"}; practice-selected=${practice.targets.length}; practice-snooze=${practiceSnooze}`,
326
492
  "info",
327
493
  );
328
494
  return;
@@ -369,14 +535,11 @@ function registerHandlers(pi: ExtensionAPI, dependencies: ResolvedDependencies):
369
535
  });
370
536
 
371
537
  pi.on("input", async (event, ctx) => {
538
+ const handlerStartedAt = Date.now();
539
+ const foregroundDeadline = handlerStartedAt + PRACTICE_CHECK_TIMEOUT_MS;
540
+ const foregroundCoordinatorDeadline = foregroundDeadline - PRACTICE_CHECK_ABORT_GRACE_MS;
372
541
  if (shuttingDown || event.source !== "interactive") return;
373
542
  ctxRef = ctx;
374
- const store = await getStore();
375
- if (shuttingDown) return;
376
- if (!hasValidConfiguration(store.getSettings(), ctx)) {
377
- publishConfigurationFailureOrClear(ctx, store);
378
- return;
379
- }
380
543
  const collected = collectPrompt(event.text, dependencies.now());
381
544
  if (!collected) return;
382
545
  const prompt = {
@@ -385,12 +548,353 @@ function registerHandlers(pi: ExtensionAPI, dependencies: ResolvedDependencies):
385
548
  .update(`${collected.promptHash}\0${inputSessionId}\0${inputSequence++}`)
386
549
  .digest("hex"),
387
550
  };
388
- if (store.hasProcessedPromptHash(prompt.promptHash)) return;
551
+ const idleTextOnly = ctx.isIdle()
552
+ && event.streamingBehavior === undefined
553
+ && (event.images?.length ?? 0) === 0;
554
+
555
+ // Enter may already have emptied Pi's editor. Preserve received bytes before any preflight I/O.
556
+ if (idleTextOnly) {
557
+ try {
558
+ ctx.ui.setEditorText(event.text);
559
+ } catch {
560
+ // Original event still owns submission. Only ordinary background collection may follow.
561
+ try {
562
+ const fallbackStore = await getStoreBefore(foregroundDeadline);
563
+ const fresh = await fallbackStore.getFreshPolicySnapshot(foregroundDeadline);
564
+ if (!shuttingDown && hasValidConfiguration(fresh.settings, ctx)) {
565
+ getWorker(ctx, fallbackStore).enqueue(prompt, fenceFromPolicy(fresh));
566
+ }
567
+ } catch { /* Original input remains fail-open. */ }
568
+ return;
569
+ }
570
+ }
571
+
572
+ let store: FluencyStore;
389
573
  try {
390
- getWorker(ctx, store).enqueue(prompt);
391
- } catch (error) {
392
- setError(ctx, analyzerErrorReason(error), error);
574
+ store = await getStoreBefore(foregroundDeadline);
575
+ } catch {
576
+ if (!idleTextOnly) return;
577
+ try {
578
+ ctx.ui.setEditorText("");
579
+ ctx.ui.notify("Sent without practice check — policy unavailable.", "warning");
580
+ return { action: "continue" };
581
+ } catch {
582
+ ctx.ui.notify("Not sent — editor could not be cleared.", "error");
583
+ return { action: "handled" };
584
+ }
585
+ }
586
+ if (shuttingDown) {
587
+ if (idleTextOnly) {
588
+ try { ctx.ui.setEditorText(""); } catch { return { action: "handled" }; }
589
+ return { action: "continue" };
590
+ }
591
+ return;
592
+ }
593
+ const queueBackground = (policy: { settings: FluencySettings; historyGeneration: string }): void => {
594
+ try {
595
+ getWorker(ctx, store).enqueue(prompt, fenceFromPolicy(policy));
596
+ } catch (error) {
597
+ setError(ctx, analyzerErrorReason(error), error);
598
+ }
599
+ };
600
+ let initialPolicy;
601
+ try {
602
+ initialPolicy = await store.getFreshPolicySnapshot(foregroundDeadline);
603
+ } catch {
604
+ if (!idleTextOnly) return;
605
+ try {
606
+ ctx.ui.setEditorText("");
607
+ ctx.ui.notify("Sent without practice check — policy unavailable.", "warning");
608
+ return { action: "continue" };
609
+ } catch {
610
+ ctx.ui.notify("Not sent — editor could not be cleared.", "error");
611
+ return { action: "handled" };
612
+ }
613
+ }
614
+ if (!hasValidConfiguration(initialPolicy.settings, ctx)) {
615
+ publishConfigurationFailureOrClear(ctx, store);
616
+ if (!idleTextOnly) return;
617
+ try { ctx.ui.setEditorText(""); } catch {
618
+ ctx.ui.notify("Not sent — editor could not be cleared.", "error");
619
+ return { action: "handled" };
620
+ }
621
+ return { action: "continue" };
622
+ }
623
+ if (store.hasProcessedPromptHash(prompt.promptHash)) {
624
+ if (!idleTextOnly) return;
625
+ try { ctx.ui.setEditorText(""); } catch { return { action: "handled" }; }
626
+ return { action: "continue" };
627
+ }
628
+ if (!idleTextOnly) {
629
+ queueBackground(initialPolicy);
630
+ return;
631
+ }
632
+ let backgroundAllowed = true;
633
+ let backgroundPolicy = initialPolicy;
634
+ const restoreInputStatus = (): void => {
635
+ if (shuttingDown || !backgroundAllowed) clearStatus(ctx);
636
+ else publishProgress(ctx, store);
637
+ };
638
+ const clearForContinue = (): boolean => {
639
+ try {
640
+ ctx.ui.setEditorText("");
641
+ return true;
642
+ } catch {
643
+ ctx.ui.notify("Not sent — editor could not be cleared.", "error");
644
+ restoreInputStatus();
645
+ return false;
646
+ }
647
+ };
648
+ const failOpen = (message: string): { action: "continue" } | { action: "handled" } => {
649
+ if (backgroundAllowed) queueBackground(backgroundPolicy);
650
+ if (!clearForContinue()) return { action: "handled" };
651
+ ctx.ui.notify(message, "warning");
652
+ restoreInputStatus();
653
+ return { action: "continue" };
654
+ };
655
+
656
+ const initialSessionSnoozed = isSessionPracticeSnoozed(ctx, store, initialPolicy.practice);
657
+ const hasActiveTarget = resolvePracticeTargets({
658
+ targets: initialPolicy.practice.targets,
659
+ patterns: store.listKnownPatterns(),
660
+ ignoredPatternKeys: new Set(initialPolicy.settings.ignoredPatternKeys),
661
+ ignoredCategories: new Set(initialPolicy.settings.ignoredCategories),
662
+ }).some((target) => target.coachingEnabled);
663
+ if (!hasValidConfiguration(initialPolicy.settings, ctx)
664
+ || !hasActiveTarget
665
+ || !isCoachingEligible({
666
+ source: event.source,
667
+ idle: true,
668
+ textOnly: true,
669
+ collectionEligible: true,
670
+ sessionSnoozed: initialSessionSnoozed,
671
+ now: dependencies.now(),
672
+ policy: initialPolicy,
673
+ })) {
674
+ if (!clearForContinue()) return { action: "handled" };
675
+ if (hasValidConfiguration(initialPolicy.settings, ctx)) queueBackground(initialPolicy);
676
+ return { action: "continue" };
677
+ }
678
+
679
+ const commitFence = fenceFromPolicy(initialPolicy);
680
+ publishStatus(ctx, { kind: "practice-check" });
681
+ const attemptController = new AbortController();
682
+ coachingControllers.add(attemptController);
683
+ let successfulResult: Awaited<ReturnType<Analyzer["analyze"]>> | undefined;
684
+ let checkPolicy = initialPolicy;
685
+ let checkSessionSnoozed = initialSessionSnoozed;
686
+ let analyzerChangeObserved = false;
687
+ let policyReadFailed = false;
688
+ let coachingUiFailed = false;
689
+ let technicalFailureMessage = practiceFailureMessage("error");
690
+ const checkPromise: Promise<CoachingCheckResult> = (async () => {
691
+ try {
692
+ const context = selectPracticeAnalysisContext(initialPolicy.practice.targets, store.listKnownPatterns());
693
+ const outcome = await getWorker(ctx, store).analyzeForeground({
694
+ analyzer: createAnalyzer(ctx, store, initialPolicy.settings),
695
+ prompt,
696
+ patterns: context.patterns,
697
+ selectedTargets: context.targetDescriptors,
698
+ deadline: foregroundCoordinatorDeadline,
699
+ signal: attemptController.signal,
700
+ abortGraceMs: PRACTICE_CHECK_ABORT_GRACE_MS,
701
+ authorize: async () => {
702
+ let fresh;
703
+ try {
704
+ fresh = await store.getFreshPolicySnapshot(foregroundCoordinatorDeadline, attemptController.signal);
705
+ } catch (error) {
706
+ policyReadFailed = true;
707
+ technicalFailureMessage = "Sent without practice check — policy unavailable.";
708
+ throw error;
709
+ }
710
+ const sessionSnoozed = isSessionPracticeSnoozed(ctx, store, fresh.practice);
711
+ checkPolicy = fresh;
712
+ checkSessionSnoozed = sessionSnoozed;
713
+ backgroundPolicy = fresh;
714
+ const revalidation = revalidateCoachingPolicy(
715
+ initialPolicy,
716
+ fresh,
717
+ initialSessionSnoozed,
718
+ sessionSnoozed,
719
+ );
720
+ if (revalidation === "analytics-disabled") backgroundAllowed = false;
721
+ return revalidation === "unchanged"
722
+ && isCoachingEligible({
723
+ source: event.source,
724
+ idle: true,
725
+ textOnly: true,
726
+ collectionEligible: true,
727
+ sessionSnoozed,
728
+ now: dependencies.now(),
729
+ policy: fresh,
730
+ });
731
+ },
732
+ });
733
+ if (outcome.kind !== "success") {
734
+ if (!policyReadFailed && !coachingUiFailed) technicalFailureMessage = practiceFailureMessage(outcome.kind);
735
+ return { kind: "failure" };
736
+ }
737
+ successfulResult = outcome.result;
738
+ try {
739
+ checkPolicy = await store.getFreshPolicySnapshot(foregroundDeadline, attemptController.signal);
740
+ } catch {
741
+ technicalFailureMessage = "Sent without practice check — policy unavailable.";
742
+ return { kind: "failure" };
743
+ }
744
+ checkSessionSnoozed = isSessionPracticeSnoozed(ctx, store, checkPolicy.practice);
745
+ backgroundPolicy = checkPolicy;
746
+ const revalidation = revalidateCoachingPolicy(
747
+ initialPolicy,
748
+ checkPolicy,
749
+ initialSessionSnoozed,
750
+ checkSessionSnoozed,
751
+ );
752
+ analyzerChangeObserved = revalidation === "analyzer-changed";
753
+ if (revalidation === "analytics-disabled"
754
+ || revalidation === "analyzer-changed"
755
+ || revalidation === "gate-changed") {
756
+ technicalFailureMessage = "Sent without practice check — practice settings changed.";
757
+ if (revalidation === "analytics-disabled") backgroundAllowed = false;
758
+ return { kind: "failure" };
759
+ }
760
+ const gateStillEligible = isCoachingEligible({
761
+ source: event.source,
762
+ idle: true,
763
+ textOnly: true,
764
+ collectionEligible: true,
765
+ sessionSnoozed: checkSessionSnoozed,
766
+ now: dependencies.now(),
767
+ policy: checkPolicy,
768
+ });
769
+ const matches = gateStillEligible
770
+ ? selectedCoachingMistakes(outcome.result, checkPolicy.settings, checkPolicy.practice)
771
+ : [];
772
+ return matches.length === 0
773
+ ? { kind: "clean" }
774
+ : { kind: "matches", mistakes: matches, targets: checkPolicy.practice.targets };
775
+ } catch {
776
+ return { kind: "failure" };
777
+ }
778
+ })();
779
+
780
+ let snoozePersisted = false;
781
+ const persistSnooze = async (action: CoachingSnoozeDecision): Promise<void> => {
782
+ snoozePersisted = true;
783
+ if (action === "snooze-session") {
784
+ try {
785
+ practiceSessionSnooze.snooze(
786
+ sessionFile(ctx),
787
+ checkPolicy.practice.epoch,
788
+ (customType, data) => pi.appendEntry(customType, data),
789
+ );
790
+ } catch {
791
+ ctx.ui.notify("Sent once; conversation snooze was not activated.", "warning");
792
+ }
793
+ return;
794
+ }
795
+ const operationDeadline = Date.now() + 1_000;
796
+ const mutation = store.snoozePracticeForFiveHours(
797
+ checkPolicy.practice.revision,
798
+ operationDeadline,
799
+ dependencies.now(),
800
+ ).then((activated) => ({ kind: "result" as const, activated }), () => ({ kind: "error" as const }));
801
+ const first = await Promise.race([
802
+ mutation,
803
+ new Promise<{ kind: "deadline" }>((resolve) => setTimeout(
804
+ () => resolve({ kind: "deadline" }),
805
+ Math.max(0, operationDeadline - Date.now()),
806
+ )),
807
+ ]);
808
+ if (first.kind === "result" && first.activated) return;
809
+
810
+ // Mutation and confirmation share one absolute second. Never manufacture a second deadline.
811
+ if (Date.now() < operationDeadline) {
812
+ try {
813
+ const authoritative = await store.getFreshPolicySnapshot(operationDeadline);
814
+ if ((authoritative.practice.snoozedUntil ?? 0) > dependencies.now()) return;
815
+ ctx.ui.notify("Sent once; 5-hour snooze was not activated.", "warning");
816
+ return;
817
+ } catch { /* Remaining deadline could not establish authoritative state. */ }
818
+ }
819
+ ctx.ui.notify("Sent once; snooze state unknown — use /fluency practice resume.", "warning");
820
+ };
821
+
822
+ let decision: CoachingOverlayDecision;
823
+ try {
824
+ decision = await (dependencies.showCoaching ?? showCoachingOverlay)(
825
+ ctx,
826
+ checkPromise,
827
+ attemptController.signal,
828
+ persistSnooze,
829
+ );
830
+ } catch {
831
+ coachingUiFailed = true;
832
+ decision = "technical-failure";
833
+ }
834
+ if (decision === "edit") {
835
+ attemptController.abort();
836
+ await checkPromise;
837
+ coachingControllers.delete(attemptController);
838
+ ctx.ui.notify("Not sent — draft remains in editor.", "info");
839
+ restoreInputStatus();
840
+ return { action: "handled" };
841
+ }
842
+ if (decision === "send-unchecked" || decision === "technical-failure") {
843
+ const shutdownInterrupted = shuttingDown;
844
+ attemptController.abort();
845
+ await checkPromise;
846
+ coachingControllers.delete(attemptController);
847
+ if (shutdownInterrupted || shuttingDown) {
848
+ backgroundAllowed = false;
849
+ return failOpen(practiceFailureMessage("shutdown"));
850
+ }
851
+ return failOpen(decision === "send-unchecked"
852
+ ? "Sent without practice check — analyzer cancelled."
853
+ : technicalFailureMessage);
854
+ }
855
+ coachingControllers.delete(attemptController);
856
+
857
+ if ((decision === "snooze-session" || decision === "snooze-five-hours") && !snoozePersisted) {
858
+ await persistSnooze(decision);
859
+ }
860
+
861
+ if (!clearForContinue()) return { action: "handled" };
862
+
863
+ if (successfulResult !== undefined) {
864
+ let finalPolicy = checkPolicy;
865
+ let finalSessionSnoozed = checkSessionSnoozed;
866
+ try {
867
+ finalPolicy = await store.getFreshPolicySnapshot(Date.now() + 1_000);
868
+ finalSessionSnoozed = isSessionPracticeSnoozed(ctx, store, finalPolicy.practice);
869
+ backgroundPolicy = finalPolicy;
870
+ } catch { /* Conditional store fence remains authoritative. */ }
871
+ const revalidation = revalidateCoachingPolicy(
872
+ initialPolicy,
873
+ finalPolicy,
874
+ initialSessionSnoozed,
875
+ finalSessionSnoozed,
876
+ );
877
+ const reuse = analysisReuseAction("continue", revalidation);
878
+ if (reuse === "commit-foreground") {
879
+ let tracked!: Promise<void>;
880
+ tracked = store.conditionalAppendAnalysis(commitFence, prompt, successfulResult)
881
+ .then((result) => {
882
+ if (result === "analyzer-stale" && !shuttingDown) queueBackground(backgroundPolicy);
883
+ else if (result === "committed" && !shuttingDown) publishProgress(ctx, store);
884
+ })
885
+ .catch((error) => {
886
+ if (!shuttingDown) setError(ctx, "store", error);
887
+ })
888
+ .finally(() => scheduledCommits.delete(tracked));
889
+ scheduledCommits.add(tracked);
890
+ } else if (reuse === "queue-background" || analyzerChangeObserved) {
891
+ queueBackground(backgroundPolicy);
892
+ }
893
+ } else {
894
+ queueBackground(backgroundPolicy);
393
895
  }
896
+ restoreInputStatus();
897
+ return { action: "continue" };
394
898
  });
395
899
 
396
900
  pi.on("agent_settled", (_event, ctx) => {
@@ -417,7 +921,14 @@ function registerHandlers(pi: ExtensionAPI, dependencies: ResolvedDependencies):
417
921
  const shutdownCtx = ctxRef;
418
922
  if (shutdownCtx) clearStatus(shutdownCtx);
419
923
  overlayController?.abort();
420
- await Promise.all([workerRef?.shutdown(), overlayOpen]);
924
+ for (const controller of coachingControllers) controller.abort();
925
+ const commits = Promise.allSettled([...scheduledCommits]);
926
+ await Promise.all([
927
+ workerRef?.shutdown(),
928
+ overlayOpen,
929
+ Promise.race([commits, new Promise<void>((resolve) => setTimeout(resolve, 100))]),
930
+ ]);
931
+ coachingControllers.clear();
421
932
  if (shutdownCtx) clearStatus(shutdownCtx);
422
933
  workerRef = undefined;
423
934
  overlayOpen = undefined;