@pi-unipi/unipi 2.0.3 → 2.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.
@@ -3,9 +3,16 @@
3
3
  */
4
4
 
5
5
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
- import { MODULES, UNIPI_EVENTS, COMPACTOR_COMMANDS, COMPACTOR_TOOLS, emitEvent } from "@pi-unipi/core";
6
+ import { MODULES, UNIPI_EVENTS, COMPACTOR_COMMANDS, COMPACTOR_TOOLS, COMPACTOR_INSTRUCTION, emitEvent } from "@pi-unipi/core";
7
7
  import { scaffoldConfig, loadConfig } from "./config/manager.js";
8
8
  import { registerCompactionHooks } from "./compaction/hooks.js";
9
+ import {
10
+ createAutoCompactionState,
11
+ decideAutoCompaction,
12
+ markAutoCompactionComplete,
13
+ markAutoCompactionError,
14
+ type AutoCompactionState,
15
+ } from "./compaction/auto-trigger.js";
9
16
  import { SessionDB, getWorktreeSuffix } from "./session/db.js";
10
17
  import { extractEventsFromToolResult } from "./session/extract.js";
11
18
  import { injectResumeSnapshot } from "./session/resume-inject.js";
@@ -26,6 +33,12 @@ function createDebugLogger(getConfig: () => { debug: boolean }) {
26
33
  };
27
34
  }
28
35
 
36
+ const formatTokenCount = (n: number): string => {
37
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
38
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
39
+ return String(n);
40
+ };
41
+
29
42
  /** Measure byte size of a tool_result event's response content. */
30
43
  function measureResponseBytes(event: any): number {
31
44
  try {
@@ -59,6 +72,7 @@ export default function compactorExtension(pi: ExtensionAPI): void {
59
72
  let sessionDB: SessionDB | null = null;
60
73
  let executor: PolyglotExecutor | null = null;
61
74
  let config = loadConfig();
75
+ let autoCompactionState: AutoCompactionState = createAutoCompactionState();
62
76
  let cachedBlocks: NormalizedBlock[] = [];
63
77
  let currentSessionId = "default";
64
78
  const counters: RuntimeCounters = {
@@ -148,6 +162,7 @@ export default function compactorExtension(pi: ExtensionAPI): void {
148
162
  runtimeStats.sessionStart = Date.now();
149
163
  runtimeStats.cacheHits = 0;
150
164
  runtimeStats.cacheBytesSaved = 0;
165
+ autoCompactionState = createAutoCompactionState();
151
166
 
152
167
  sessionDB?.ensureSession(fullSessionId, projectDir);
153
168
 
@@ -284,6 +299,64 @@ export default function compactorExtension(pi: ExtensionAPI): void {
284
299
  }
285
300
  });
286
301
 
302
+ pi.on("turn_end", async (_event, ctx) => {
303
+ const cwd = (ctx as any).cwd ?? process.cwd();
304
+ config = loadConfig(cwd);
305
+
306
+ const decision = decideAutoCompaction({
307
+ config: config.autoCompaction,
308
+ usage: ctx.getContextUsage?.(),
309
+ state: autoCompactionState,
310
+ nowMs: Date.now(),
311
+ });
312
+ autoCompactionState = decision.state;
313
+
314
+ debug("auto_compaction_decision", {
315
+ enabled: config.autoCompaction.enabled,
316
+ reason: decision.reason,
317
+ shouldTrigger: decision.shouldTrigger,
318
+ percent: decision.usage?.percent,
319
+ tokens: decision.usage?.tokens,
320
+ thresholdPercent: decision.thresholdPercent,
321
+ cooldownRemainingMs: decision.cooldownRemainingMs,
322
+ tokenGrowth: decision.tokenGrowth,
323
+ });
324
+
325
+ if (!decision.shouldTrigger) return;
326
+
327
+ const notify = config.autoCompaction.notify;
328
+ if (notify && decision.usage) {
329
+ ctx.ui.notify(
330
+ `Auto-compacting at ${decision.usage.percent.toFixed(1)}% context (~${formatTokenCount(decision.usage.tokens)} tokens; threshold ${decision.thresholdPercent}%).`,
331
+ "info",
332
+ );
333
+ }
334
+
335
+ try {
336
+ ctx.compact({
337
+ customInstructions: COMPACTOR_INSTRUCTION,
338
+ onComplete: () => {
339
+ autoCompactionState = markAutoCompactionComplete(autoCompactionState);
340
+ if (notify) {
341
+ ctx.ui.notify("Auto-compaction completed.", "info");
342
+ }
343
+ },
344
+ onError: (err: Error) => {
345
+ autoCompactionState = markAutoCompactionError(autoCompactionState, Date.now());
346
+ const benign = err.message === "Compaction cancelled" || err.message === "Already compacted";
347
+ if (notify && !benign) {
348
+ ctx.ui.notify(`Auto-compaction failed: ${err.message}`, "warning");
349
+ }
350
+ },
351
+ });
352
+ } catch (err) {
353
+ autoCompactionState = markAutoCompactionError(autoCompactionState, Date.now());
354
+ if (notify) {
355
+ ctx.ui.notify(`Auto-compaction failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
356
+ }
357
+ }
358
+ });
359
+
287
360
  pi.on("session_before_compact", async (event, _ctx) => {
288
361
  if (sessionDB) {
289
362
  // Use closure currentSessionId — Pi's session_before_compact event
@@ -2,6 +2,8 @@
2
2
  * context_budget tool — estimate remaining context window
3
3
  */
4
4
 
5
+ import type { AutoCompactionConfig } from "../types.js";
6
+
5
7
  export interface ContextBudgetResult {
6
8
  percentFull: number;
7
9
  remainingTokens: number;
@@ -13,6 +15,7 @@ export interface ContextBudgetResult {
13
15
  export function estimateContextBudget(
14
16
  tokensBefore?: number,
15
17
  contextWindowSize?: number,
18
+ autoCompaction?: Pick<AutoCompactionConfig, "enabled" | "thresholdPercent">,
16
19
  ): ContextBudgetResult | null {
17
20
  const windowSize = contextWindowSize ?? 200000; // Default 200K context
18
21
  const used = tokensBefore ?? 0;
@@ -33,6 +36,15 @@ export function estimateContextBudget(
33
36
  advice = "Context has plenty of room. No compaction needed yet.";
34
37
  }
35
38
 
39
+ if (autoCompaction?.enabled) {
40
+ const threshold = autoCompaction.thresholdPercent;
41
+ if (percentFull >= threshold) {
42
+ advice += ` UniPi percentage auto-compaction is enabled at ${threshold}% and usage is above that threshold, subject to cooldown/repeat safeguards.`;
43
+ } else {
44
+ advice += ` UniPi percentage auto-compaction is enabled at ${threshold}%.`;
45
+ }
46
+ }
47
+
36
48
  const message = `Context: ~${percentFull}% full (estimated ${remaining.toLocaleString()} tokens remaining)`;
37
49
 
38
50
  return { percentFull, remainingTokens: remaining, totalTokens: windowSize, message, advice };
@@ -42,8 +54,12 @@ export function estimateContextBudget(
42
54
  * The context_budget tool handler.
43
55
  * Called from the tool registration — receives tokensBefore from Pi context.
44
56
  */
45
- export function contextBudgetTool(tokensBefore?: number): string {
46
- const budget = estimateContextBudget(tokensBefore);
57
+ export function contextBudgetTool(
58
+ tokensBefore?: number,
59
+ contextWindowSize?: number,
60
+ autoCompaction?: Pick<AutoCompactionConfig, "enabled" | "thresholdPercent">,
61
+ ): string {
62
+ const budget = estimateContextBudget(tokensBefore, contextWindowSize, autoCompaction);
47
63
  if (!budget) return "Context budget: Unknown (no token data available from session).";
48
64
 
49
65
  return `${budget.message}\nAdvice: ${budget.advice}`;
@@ -291,17 +291,25 @@ export function registerCompactorTools(pi: ExtensionAPI, deps: CompactorToolDeps
291
291
  label: "Context Budget",
292
292
  description: "Estimate remaining context window (% full, tokens left) and get advice on whether to compact.",
293
293
  parameters: Type.Object({}),
294
- async execute(): Promise<import("@mariozechner/pi-coding-agent").AgentToolResult<unknown>> {
295
- const blocks = deps.getBlocks();
296
- const estimatedTokens = blocks.reduce((sum, b) => {
297
- const text = b.kind === "tool_call"
298
- ? `${b.name} ${JSON.stringify((b as any).args ?? {})}`
299
- : b.kind === "tool_result"
300
- ? `${b.name} ${(b as any).text ?? ""}`
301
- : (b as any).text ?? "";
302
- return sum + Math.ceil(text.length / 4);
303
- }, 0);
304
- const message = contextBudgetTool(estimatedTokens);
294
+ async execute(_toolCallId: string, _params: any, _signal?: AbortSignal, _onUpdate?: unknown, ctx?: ExtensionContext): Promise<import("@mariozechner/pi-coding-agent").AgentToolResult<unknown>> {
295
+ const config = loadConfig(ctx?.cwd ?? process.cwd());
296
+ const liveUsage = ctx?.getContextUsage?.();
297
+ let estimatedTokens: number | undefined = liveUsage?.tokens ?? undefined;
298
+ let contextWindow = liveUsage?.contextWindow;
299
+
300
+ if (estimatedTokens === undefined) {
301
+ const blocks = deps.getBlocks();
302
+ estimatedTokens = blocks.reduce((sum, b) => {
303
+ const text = b.kind === "tool_call"
304
+ ? `${b.name} ${JSON.stringify((b as any).args ?? {})}`
305
+ : b.kind === "tool_result"
306
+ ? `${b.name} ${(b as any).text ?? ""}`
307
+ : (b as any).text ?? "";
308
+ return sum + Math.ceil(text.length / 4);
309
+ }, 0);
310
+ }
311
+
312
+ const message = contextBudgetTool(estimatedTokens, contextWindow, config.autoCompaction);
305
313
  return textResult(message);
306
314
  },
307
315
  } as any));
@@ -18,8 +18,8 @@ import { existsSync, unlinkSync } from "node:fs";
18
18
 
19
19
  // ─── Section types ─────────────────────────────────────────────────────
20
20
 
21
- type Section = "presets" | "strategies" | "pipeline";
22
- const SECTIONS: Section[] = ["presets", "strategies", "pipeline"];
21
+ type Section = "presets" | "strategies" | "auto" | "pipeline";
22
+ const SECTIONS: Section[] = ["presets", "strategies", "auto", "pipeline"];
23
23
 
24
24
  // ─── Strategy item definition ──────────────────────────────────────────
25
25
 
@@ -160,6 +160,10 @@ const PIPELINE_ITEMS: PipelineDef[] = [
160
160
 
161
161
  const PRESETS: CompactorPreset[] = ["precise", "balanced", "thorough", "lean"];
162
162
 
163
+ const THRESHOLD_VALUES = ["60%", "70%", "75%", "80%", "85%", "90%", "95%"];
164
+ const COOLDOWN_VALUES = ["0s", "30s", "60s", "5m", "10m"];
165
+ const REPEAT_GROWTH_VALUES = ["0", "1k", "4k", "8k", "16k", "32k"];
166
+
163
167
  const PRESET_DESCRIPTIONS: Record<string, { summary: string; detail: string }> = {
164
168
  precise: {
165
169
  summary: "Code-heavy, minimal waste — compaction: full, sandbox: safe-only",
@@ -207,6 +211,40 @@ function borderLine(innerWidth: number, edge: "top" | "bottom"): string {
207
211
  return `\x1b[90m${left}${"─".repeat(innerWidth)}${right}\x1b[0m`;
208
212
  }
209
213
 
214
+ function uniqueValues(values: string[]): string[] {
215
+ return [...new Set(values)];
216
+ }
217
+
218
+ function formatPercent(value: number): string {
219
+ return `${Math.round(value)}%`;
220
+ }
221
+
222
+ function parsePercent(value: string): number {
223
+ return Number(value.replace("%", ""));
224
+ }
225
+
226
+ function formatCooldown(ms: number): string {
227
+ if (ms === 0) return "0s";
228
+ if (ms % 60_000 === 0) return `${ms / 60_000}m`;
229
+ return `${Math.round(ms / 1000)}s`;
230
+ }
231
+
232
+ function parseCooldown(value: string): number {
233
+ if (value.endsWith("m")) return Number(value.slice(0, -1)) * 60_000;
234
+ if (value.endsWith("s")) return Number(value.slice(0, -1)) * 1000;
235
+ return Number(value);
236
+ }
237
+
238
+ function formatGrowthTokens(tokens: number): string {
239
+ if (tokens >= 1000 && tokens % 1000 === 0) return `${tokens / 1000}k`;
240
+ return String(tokens);
241
+ }
242
+
243
+ function parseGrowthTokens(value: string): number {
244
+ if (value.endsWith("k")) return Number(value.slice(0, -1)) * 1000;
245
+ return Number(value);
246
+ }
247
+
210
248
  // ─── Main component ────────────────────────────────────────────────────
211
249
 
212
250
  /**
@@ -224,6 +262,7 @@ export class CompactorSettingsOverlay implements Component {
224
262
  // Per-section SettingsList instances
225
263
  private presetList!: SettingsList;
226
264
  private strategyList!: SettingsList;
265
+ private autoList!: SettingsList;
227
266
  private pipelineList!: SettingsList;
228
267
 
229
268
  constructor(opts?: { cwd?: string }) {
@@ -290,6 +329,53 @@ export class CompactorSettingsOverlay implements Component {
290
329
  { enableSearch: true },
291
330
  );
292
331
 
332
+ // ── Auto-compaction trigger list ──────────────────────────────────
333
+ const autoItems: SettingItem[] = [
334
+ {
335
+ id: "auto:enabled",
336
+ label: "Percentage Trigger",
337
+ description: "UniPi-managed auto-compaction based on context percentage",
338
+ currentValue: this.config.autoCompaction.enabled ? "on" : "off",
339
+ values: ["on", "off"],
340
+ },
341
+ {
342
+ id: "auto:thresholdPercent",
343
+ label: "Threshold",
344
+ description: "Trigger when Pi reports context usage at or above this percent",
345
+ currentValue: formatPercent(this.config.autoCompaction.thresholdPercent),
346
+ values: uniqueValues([...THRESHOLD_VALUES, formatPercent(this.config.autoCompaction.thresholdPercent)]),
347
+ },
348
+ {
349
+ id: "auto:cooldownMs",
350
+ label: "Cooldown",
351
+ description: "Minimum delay between UniPi-triggered compaction attempts",
352
+ currentValue: formatCooldown(this.config.autoCompaction.cooldownMs),
353
+ values: uniqueValues([...COOLDOWN_VALUES, formatCooldown(this.config.autoCompaction.cooldownMs)]),
354
+ },
355
+ {
356
+ id: "auto:repeatMinGrowthTokens",
357
+ label: "Repeat Growth",
358
+ description: "If still above threshold after compaction, require this many new tokens",
359
+ currentValue: formatGrowthTokens(this.config.autoCompaction.repeatMinGrowthTokens),
360
+ values: uniqueValues([...REPEAT_GROWTH_VALUES, formatGrowthTokens(this.config.autoCompaction.repeatMinGrowthTokens)]),
361
+ },
362
+ {
363
+ id: "auto:notify",
364
+ label: "Notifications",
365
+ description: "Notify when UniPi auto-compaction triggers or fails",
366
+ currentValue: this.config.autoCompaction.notify ? "on" : "off",
367
+ values: ["on", "off"],
368
+ },
369
+ ];
370
+
371
+ this.autoList = new SettingsList(
372
+ autoItems,
373
+ 8,
374
+ THEME,
375
+ (id, newValue) => this.onAutoChange(id, newValue),
376
+ () => this.onCancel(),
377
+ );
378
+
293
379
  // ── Pipeline list ─────────────────────────────────────────────────
294
380
  const pipelineItems: SettingItem[] = PIPELINE_ITEMS.map((p) => ({
295
381
  id: `pipeline:${p.key}`,
@@ -312,6 +398,7 @@ export class CompactorSettingsOverlay implements Component {
312
398
 
313
399
  private get currentList(): SettingsList {
314
400
  if (this.section === "strategies") return this.strategyList;
401
+ if (this.section === "auto") return this.autoList;
315
402
  if (this.section === "pipeline") return this.pipelineList;
316
403
  return this.presetList;
317
404
  }
@@ -332,8 +419,9 @@ export class CompactorSettingsOverlay implements Component {
332
419
  const presetName = id.replace("preset:", "") as CompactorPreset;
333
420
  if (PRESETS.includes(presetName)) {
334
421
  this.config = applyPreset(presetName);
335
- // Update all strategy/pipeline items to reflect new config
422
+ // Update all strategy/auto/pipeline items to reflect new config
336
423
  this.refreshStrategyValues();
424
+ this.refreshAutoValues();
337
425
  this.refreshPipelineValues();
338
426
  // Update preset indicators
339
427
  for (const name of PRESETS) {
@@ -366,6 +454,39 @@ export class CompactorSettingsOverlay implements Component {
366
454
  }
367
455
  }
368
456
 
457
+ private onAutoChange(id: string, newValue: string): void {
458
+ const key = id.replace("auto:", "");
459
+ switch (key) {
460
+ case "enabled":
461
+ this.config.autoCompaction.enabled = newValue === "on";
462
+ break;
463
+ case "thresholdPercent":
464
+ this.config.autoCompaction.thresholdPercent = parsePercent(newValue);
465
+ break;
466
+ case "cooldownMs":
467
+ this.config.autoCompaction.cooldownMs = parseCooldown(newValue);
468
+ break;
469
+ case "repeatMinGrowthTokens":
470
+ this.config.autoCompaction.repeatMinGrowthTokens = parseGrowthTokens(newValue);
471
+ break;
472
+ case "notify":
473
+ this.config.autoCompaction.notify = newValue === "on";
474
+ break;
475
+ default:
476
+ return;
477
+ }
478
+
479
+ this.autoList.updateValue(id, this.formatAutoValue(key));
480
+
481
+ // Update preset indicators
482
+ for (const name of PRESETS) {
483
+ this.presetList.updateValue(
484
+ `preset:${name}`,
485
+ detectPreset(this.config) === name ? "✓ active" : "",
486
+ );
487
+ }
488
+ }
489
+
369
490
  private onPipelineChange(id: string, newValue: string): void {
370
491
  const key = id.replace("pipeline:", "");
371
492
  const item = PIPELINE_ITEMS.find((p) => p.key === key);
@@ -395,12 +516,30 @@ export class CompactorSettingsOverlay implements Component {
395
516
  return mode;
396
517
  }
397
518
 
519
+ private formatAutoValue(key: string): string {
520
+ const auto = this.config.autoCompaction;
521
+ switch (key) {
522
+ case "enabled": return auto.enabled ? "on" : "off";
523
+ case "thresholdPercent": return formatPercent(auto.thresholdPercent);
524
+ case "cooldownMs": return formatCooldown(auto.cooldownMs);
525
+ case "repeatMinGrowthTokens": return formatGrowthTokens(auto.repeatMinGrowthTokens);
526
+ case "notify": return auto.notify ? "on" : "off";
527
+ default: return "";
528
+ }
529
+ }
530
+
398
531
  private refreshStrategyValues(): void {
399
532
  for (const s of STRATEGIES) {
400
533
  this.strategyList.updateValue(`strategy:${s.key}`, this.formatStrategyValue(s));
401
534
  }
402
535
  }
403
536
 
537
+ private refreshAutoValues(): void {
538
+ for (const key of ["enabled", "thresholdPercent", "cooldownMs", "repeatMinGrowthTokens", "notify"]) {
539
+ this.autoList.updateValue(`auto:${key}`, this.formatAutoValue(key));
540
+ }
541
+ }
542
+
404
543
  private refreshPipelineValues(): void {
405
544
  for (const p of PIPELINE_ITEMS) {
406
545
  this.pipelineList.updateValue(`pipeline:${p.key}`, p.getValue(this.config) ? "on" : "off");
@@ -88,6 +88,20 @@ export interface CompactorStrategyConfig {
88
88
  autoDetect?: "git" | null;
89
89
  }
90
90
 
91
+ /** UniPi-managed percentage auto-compaction trigger settings. */
92
+ export interface AutoCompactionConfig {
93
+ /** Enable the extension-managed percentage trigger. Disabled by default for backward compatibility. */
94
+ enabled: boolean;
95
+ /** Trigger when Pi reports context usage at or above this percent (0-100 scale). */
96
+ thresholdPercent: number;
97
+ /** Minimum delay between UniPi-triggered compaction attempts. */
98
+ cooldownMs: number;
99
+ /** When usage stays above threshold after compaction, require this many new tokens before repeating. */
100
+ repeatMinGrowthTokens: number;
101
+ /** Show user notifications for UniPi-triggered compaction attempts/results. */
102
+ notify: boolean;
103
+ }
104
+
91
105
  export interface CompactorConfig {
92
106
  // Compaction strategies
93
107
  sessionGoals: CompactorStrategyConfig & { mode: "full" | "brief" | "off" };
@@ -112,6 +126,9 @@ export interface CompactorConfig {
112
126
  customNoisePatterns: string[];
113
127
  };
114
128
 
129
+ // Auto compaction trigger
130
+ autoCompaction: AutoCompactionConfig;
131
+
115
132
  // Global settings
116
133
  overrideDefaultCompaction: boolean;
117
134
  debug: boolean;
@@ -409,6 +409,8 @@ export interface UnipiNotificationSentEvent {
409
409
  platforms: string[];
410
410
  /** Whether all platforms succeeded */
411
411
  success: boolean;
412
+ /** Platforms where notification was suppressed (e.g. window focused) */
413
+ suppressedPlatforms?: string[];
412
414
  /** ISO timestamp */
413
415
  timestamp: string;
414
416
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  Push notifications when things happen. Workflow finishes, Ralph loop completes, MCP server errors — notify sends alerts to native OS, Gotify, Telegram, or ntfy.
4
4
 
5
- Configure once, get alerts everywhere. Per-event platform routing lets you send critical errors to Telegram and routine completions to Gotify.
5
+ Configure once, get alerts everywhere. Per-event platform routing lets you send critical errors to Telegram and routine completions to Gotify. Native desktop notifications can also be suppressed while the Pi window is focused.
6
6
 
7
7
  ## Commands
8
8
 
@@ -53,7 +53,7 @@ Desktop notifications via [node-notifier](https://github.com/mikaelbr/node-notif
53
53
  - **macOS:** terminal-notifier
54
54
  - **Linux:** notify-send / libnotify
55
55
 
56
- Zero configuration — works out of the box.
56
+ Zero configuration — works out of the box. Set `native.suppressWhenFocused` to `true` to skip native notifications when the active/focused window is already Pi.
57
57
 
58
58
  ### Gotify
59
59
 
@@ -14,7 +14,7 @@ import { NtfySetupOverlay } from "./tui/ntfy-setup.js";
14
14
  import { RecapModelSelectorOverlay } from "./tui/recap-model-selector.js";
15
15
  import { loadConfig } from "./settings.js";
16
16
  import { loadNtfyConfig } from "./ntfy-config.js";
17
- import { sendNativeNotification } from "./platforms/native.js";
17
+ import { sendNativeNotification, SuppressedError } from "./platforms/native.js";
18
18
  import { sendGotifyNotification } from "./platforms/gotify.js";
19
19
  import { sendTelegramNotification } from "./platforms/telegram.js";
20
20
  import { sendNtfyNotification } from "./platforms/ntfy.js";
@@ -267,12 +267,17 @@ export function registerNotifyCommands(pi: ExtensionAPI): void {
267
267
  try {
268
268
  await sendNativeNotification(title, message, {
269
269
  windowsAppId: config.native.windowsAppId,
270
+ suppressWhenFocused: config.native.suppressWhenFocused,
270
271
  });
271
272
  results.push("✓ Native: sent");
272
273
  } catch (err) {
273
- results.push(
274
- `✗ Native: ${err instanceof Error ? err.message : "failed"}`
275
- );
274
+ if (err instanceof SuppressedError) {
275
+ results.push("— Native: suppressed (window focused)");
276
+ } else {
277
+ results.push(
278
+ `✗ Native: ${err instanceof Error ? err.message : "failed"}`
279
+ );
280
+ }
276
281
  }
277
282
  }
278
283
 
@@ -9,7 +9,7 @@ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-age
9
9
  import { UNIPI_EVENTS, emitEvent } from "@pi-unipi/core";
10
10
  import type { NotifyConfig, NotifyPlatform, NotifyDispatchResult } from "./types.js";
11
11
  import { loadNtfyConfig } from "./ntfy-config.js";
12
- import { sendNativeNotification } from "./platforms/native.js";
12
+ import { sendNativeNotification, SuppressedError } from "./platforms/native.js";
13
13
  import { sendGotifyNotification } from "./platforms/gotify.js";
14
14
  import { sendTelegramNotification } from "./platforms/telegram.js";
15
15
  import { sendNtfyNotification } from "./platforms/ntfy.js";
@@ -184,6 +184,10 @@ export async function dispatchNotification(
184
184
  await sendToPlatform(platform, title, message, config, cwd);
185
185
  return { platform, success: true };
186
186
  } catch (err) {
187
+ // SuppressedError is intentional, not a failure
188
+ if (err instanceof SuppressedError) {
189
+ return { platform, success: true, suppressed: true };
190
+ }
187
191
  // Silently ignore — platform send failure is tracked in results.
188
192
  return {
189
193
  platform,
@@ -194,13 +198,18 @@ export async function dispatchNotification(
194
198
  })
195
199
  );
196
200
 
197
- const allSuccess = results.length > 0 && results.every((r) => r.success);
201
+ const unsuppressed = results.filter((r) => !r.suppressed);
202
+ const allSuccess = results.length > 0 && unsuppressed.every((r) => r.success);
203
+ const suppressedPlatforms = results
204
+ .filter((r) => r.suppressed)
205
+ .map((r) => r.platform);
198
206
 
199
207
  // Emit notification sent event
200
208
  emitEvent(pi, UNIPI_EVENTS.NOTIFICATION_SENT, {
201
209
  eventType,
202
210
  platforms: enabledPlatforms,
203
211
  success: allSuccess,
212
+ ...(suppressedPlatforms.length > 0 && { suppressedPlatforms }),
204
213
  timestamp: new Date().toISOString(),
205
214
  });
206
215
 
@@ -219,6 +228,7 @@ async function sendToPlatform(
219
228
  case "native":
220
229
  await sendNativeNotification(title, message, {
221
230
  windowsAppId: config.native.windowsAppId,
231
+ suppressWhenFocused: config.native.suppressWhenFocused,
222
232
  });
223
233
  break;
224
234
  case "gotify":
@@ -0,0 +1,123 @@
1
+ /**
2
+ * @pi-unipi/notify — Windows focus detection
3
+ *
4
+ * Checks whether the terminal window is the foreground (active) window
5
+ * by walking the WMI process tree upward from the current process PID
6
+ * and comparing each ancestor against the foreground window's owner PID.
7
+ *
8
+ * This approach works reliably across cmd, PowerShell, and Windows
9
+ * Terminal, unlike GetConsoleWindow which returns NULL in spawned
10
+ * child processes.
11
+ *
12
+ * Requires PowerShell (built-in on Windows 7+).
13
+ */
14
+
15
+ import { execFile } from "child_process";
16
+ import { writeFileSync, rmSync, mkdtempSync } from "fs";
17
+ import { join } from "path";
18
+ import { tmpdir } from "os";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // PowerShell script (embedded)
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const POWERCHECK_SCRIPT = `
25
+ param($targetPid)
26
+ Add-Type @'
27
+ using System;
28
+ using System.Runtime.InteropServices;
29
+ public class WinAPI {
30
+ [DllImport("user32.dll", SetLastError = false)]
31
+ public static extern IntPtr GetForegroundWindow();
32
+ [DllImport("user32.dll", SetLastError = false)]
33
+ public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
34
+ }
35
+ '@ | Out-Null
36
+ $fgHwnd = [WinAPI]::GetForegroundWindow()
37
+ [uint32]$fgPid = 0
38
+ [void][WinAPI]::GetWindowThreadProcessId($fgHwnd, [ref]$fgPid)
39
+ $curPid = $targetPid
40
+ $maxDepth = 20
41
+ while ($curPid -gt 0 -and $maxDepth-- -gt 0) {
42
+ if ($curPid -eq $fgPid) { Write-Host -NoNewline 'True'; exit }
43
+ $proc = Get-CimInstance -Class Win32_Process -Filter "ProcessId = $curPid" -ErrorAction SilentlyContinue | Select-Object -Property ParentProcessId
44
+ if (-not $proc) { break }
45
+ $curPid = $proc.ParentProcessId
46
+ }
47
+ Write-Host -NoNewline 'False'
48
+ `;
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Cache — avoid spawning PowerShell on every check
52
+ // ---------------------------------------------------------------------------
53
+
54
+ let cached: { result: boolean; time: number } | null = null;
55
+ const CACHE_TTL_MS = 500;
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Public API
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /**
62
+ * Returns true when the terminal window that owns the current process is
63
+ * the foreground (active) window on Windows.
64
+ *
65
+ * Works by:
66
+ * 1. Calling Win32 GetForegroundWindow + GetWindowThreadProcessId to
67
+ * obtain the foreground window's owning PID.
68
+ * 2. Walking the WMI Win32_Process parent chain upward from
69
+ * process.pid.
70
+ * 3. If any ancestor PID matches the foreground PID the terminal is
71
+ * considered focused.
72
+ *
73
+ * The result is cached for 500 ms to avoid spawning PowerShell on rapid
74
+ * consecutive checks (e.g. batch notifications).
75
+ */
76
+ export async function isWindowFocusedOnWindows(): Promise<boolean> {
77
+ const now = Date.now();
78
+ if (cached && now - cached.time < CACHE_TTL_MS) {
79
+ return cached.result;
80
+ }
81
+
82
+ let tmpDir: string | null = null;
83
+ try {
84
+ tmpDir = mkdtempSync(join(tmpdir(), "pi-focus-"));
85
+ const scriptPath = join(tmpDir, "check.ps1");
86
+ writeFileSync(scriptPath, POWERCHECK_SCRIPT, "utf-8");
87
+
88
+ const stdout = await new Promise<string>((resolve, reject) => {
89
+ execFile(
90
+ "powershell.exe",
91
+ [
92
+ "-NoProfile",
93
+ "-NonInteractive",
94
+ "-ExecutionPolicy",
95
+ "Bypass",
96
+ "-File",
97
+ scriptPath,
98
+ String(process.pid),
99
+ ],
100
+ { timeout: 5000, encoding: "utf-8" },
101
+ (err, out) => {
102
+ if (err) reject(err);
103
+ else resolve(out);
104
+ }
105
+ );
106
+ });
107
+
108
+ cached = { result: stdout.trim() === "True", time: Date.now() };
109
+ return cached.result;
110
+ } catch {
111
+ // Detection failure → safe default: assume NOT focused (don't suppress)
112
+ cached = { result: false, time: Date.now() };
113
+ return false;
114
+ } finally {
115
+ if (tmpDir) {
116
+ try {
117
+ rmSync(tmpDir, { recursive: true });
118
+ } catch {
119
+ // Temp file cleanup is non-critical
120
+ }
121
+ }
122
+ }
123
+ }