@pi-unipi/unipi 2.1.3 → 2.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.
Files changed (90) hide show
  1. package/README.md +6 -2
  2. package/package.json +23 -22
  3. package/packages/ask-user/ask-ui.ts +45 -31
  4. package/packages/ask-user/launcher-ui.ts +26 -22
  5. package/packages/ask-user/package.json +2 -2
  6. package/packages/ask-user/settings-tui.ts +22 -9
  7. package/packages/autocomplete/package.json +1 -1
  8. package/packages/autocomplete/src/constants.ts +5 -0
  9. package/packages/btw/package.json +2 -2
  10. package/packages/cocoindex/package.json +2 -2
  11. package/packages/compactor/package.json +3 -3
  12. package/packages/compactor/src/tui/settings-overlay.ts +2 -1
  13. package/packages/core/constants.ts +18 -0
  14. package/packages/core/index.ts +1 -0
  15. package/packages/core/package.json +1 -1
  16. package/packages/core/tui-width.ts +127 -0
  17. package/packages/footer/package.json +2 -2
  18. package/packages/footer/src/tui/settings-tui.ts +2 -1
  19. package/packages/image/README.md +82 -0
  20. package/packages/image/index.ts +1 -0
  21. package/packages/image/package.json +57 -0
  22. package/packages/image/skills/image/SKILL.md +73 -0
  23. package/packages/image/src/commands.ts +17 -0
  24. package/packages/image/src/generate.ts +201 -0
  25. package/packages/image/src/image-source.ts +204 -0
  26. package/packages/image/src/index.ts +99 -0
  27. package/packages/image/src/models.ts +290 -0
  28. package/packages/image/src/recognize.ts +223 -0
  29. package/packages/image/src/settings.ts +149 -0
  30. package/packages/image/src/tools.ts +296 -0
  31. package/packages/image/src/tui/model-selector.ts +279 -0
  32. package/packages/image/src/tui/settings-dialog.ts +236 -0
  33. package/packages/info-screen/package.json +2 -2
  34. package/packages/info-screen/tui/info-overlay.ts +505 -0
  35. package/packages/input-shortcuts/package.json +2 -2
  36. package/packages/kanboard/package.json +2 -2
  37. package/packages/kanboard/tui/kanboard-overlay.ts +300 -0
  38. package/packages/mcp/package.json +2 -2
  39. package/packages/mcp/src/tui/add-overlay.ts +10 -7
  40. package/packages/mcp/src/tui/settings-overlay.ts +10 -7
  41. package/packages/memory/package.json +3 -3
  42. package/packages/memory/tui/settings-tui.ts +281 -0
  43. package/packages/milestone/package.json +2 -2
  44. package/packages/notify/README.md +2 -0
  45. package/packages/notify/events.ts +11 -0
  46. package/packages/notify/package.json +2 -2
  47. package/packages/notify/permission-prompt-message.ts +95 -0
  48. package/packages/notify/platforms/focus-win.ts +123 -0
  49. package/packages/notify/platforms/focus.ts +33 -0
  50. package/packages/notify/platforms/gotify.ts +36 -0
  51. package/packages/notify/platforms/native.ts +79 -0
  52. package/packages/notify/platforms/node-notifier.d.ts +20 -0
  53. package/packages/notify/platforms/ntfy.ts +48 -0
  54. package/packages/notify/platforms/telegram.ts +77 -0
  55. package/packages/notify/settings.ts +1 -0
  56. package/packages/notify/skills/configure-notify/SKILL.md +28 -1
  57. package/packages/notify/src/__tests__/permission-prompt-message.test.ts +172 -0
  58. package/packages/notify/tui/gotify-setup.ts +529 -0
  59. package/packages/notify/tui/ntfy-setup.ts +653 -0
  60. package/packages/notify/tui/recap-model-selector.ts +301 -0
  61. package/packages/notify/tui/settings-overlay.ts +353 -0
  62. package/packages/notify/tui/telegram-setup.ts +302 -0
  63. package/packages/ralph/package.json +3 -3
  64. package/packages/subagents/package.json +4 -4
  65. package/packages/subagents/src/__tests__/shutdown-stale-ctx.test.ts +185 -0
  66. package/packages/subagents/src/index.ts +52 -10
  67. package/packages/unipi/index.ts +2 -0
  68. package/packages/updater/package.json +2 -2
  69. package/packages/updater/src/tui/changelog-overlay.ts +2 -2
  70. package/packages/updater/src/tui/readme-overlay.ts +2 -1
  71. package/packages/updater/src/tui/update-overlay.ts +2 -1
  72. package/packages/utility/package.json +2 -2
  73. package/packages/utility/src/tui/badge-settings-tui.ts +2 -2
  74. package/packages/utility/src/tui/util-settings-tui.ts +2 -2
  75. package/packages/web-api/README.md +28 -11
  76. package/packages/web-api/package.json +8 -2
  77. package/packages/web-api/skills/web/SKILL.md +36 -9
  78. package/packages/web-api/src/index.ts +15 -0
  79. package/packages/web-api/src/providers/duckduckgo.ts +71 -27
  80. package/packages/web-api/src/providers/firecrawl.ts +1 -1
  81. package/packages/web-api/src/providers/jina-reader.ts +1 -1
  82. package/packages/web-api/src/providers/jina-search.ts +1 -1
  83. package/packages/web-api/src/providers/perplexity.ts +2 -2
  84. package/packages/web-api/src/providers/serpapi.ts +1 -1
  85. package/packages/web-api/src/providers/tavily.ts +1 -1
  86. package/packages/web-api/src/providers/wigolo-client.ts +196 -0
  87. package/packages/web-api/src/providers/wigolo.ts +117 -0
  88. package/packages/web-api/src/settings.ts +1 -0
  89. package/packages/web-api/src/tools.ts +93 -36
  90. package/packages/workflow/package.json +2 -2
@@ -0,0 +1,281 @@
1
+ /**
2
+ * @unipi/memory — Settings TUI
3
+ *
4
+ * Interactive settings dialog for embedding configuration.
5
+ * Uses ctx.ui primitives (select, input, notify).
6
+ */
7
+
8
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
9
+ import {
10
+ loadEmbeddingConfig,
11
+ saveEmbeddingConfig,
12
+ setApiKey,
13
+ clearApiKey,
14
+ getApiKey,
15
+ isEmbeddingReady,
16
+ hasModelChanged,
17
+ markModelUsed,
18
+ OPENROUTER_EMBEDDING_MODELS,
19
+ type EmbeddingConfig,
20
+ } from "../settings.js";
21
+
22
+ /**
23
+ * Show memory settings dialog.
24
+ * Main entry point for /unipi:memory-settings command.
25
+ */
26
+ export async function showMemorySettings(ctx: ExtensionCommandContext): Promise<void> {
27
+ const ui = ctx.ui;
28
+ let running = true;
29
+
30
+ while (running) {
31
+ const config = loadEmbeddingConfig();
32
+ const hasKey = !!getApiKey();
33
+ const ready = isEmbeddingReady();
34
+
35
+ // Build status lines
36
+ const statusLines = [
37
+ `Provider: ${config.provider === "none" ? "None (fuzzy-only)" : "OpenRouter"}`,
38
+ `Model: ${config.model || "N/A"}`,
39
+ `Dimensions: ${config.dimensions}`,
40
+ `API Key: ${hasKey ? "✓ Set" : "✗ Not set"}`,
41
+ `Status: ${ready ? "✓ Ready" : "⚠ Not configured"}`,
42
+ ];
43
+
44
+ if (hasModelChanged() && !config.suppressMigrationWarning) {
45
+ statusLines.push("");
46
+ statusLines.push("⚠ Model changed — old embeddings incompatible.");
47
+ statusLines.push(" Re-embed to use vector search with new model.");
48
+ }
49
+
50
+ const options = [];
51
+
52
+ // API key management
53
+ if (hasKey) {
54
+ options.push({
55
+ label: "🔑 Update API Key",
56
+ value: "__update_key__",
57
+ description: "Update your OpenRouter API key",
58
+ });
59
+ options.push({
60
+ label: "🗑️ Remove API Key",
61
+ value: "__remove_key__",
62
+ description: "Remove API key and disable vector search",
63
+ });
64
+ } else {
65
+ options.push({
66
+ label: "🔑 Add API Key",
67
+ value: "__add_key__",
68
+ description: "Add OpenRouter API key to enable vector search",
69
+ });
70
+ }
71
+
72
+ // Model selection
73
+ options.push({
74
+ label: `📦 Select Model (current: ${config.model})`,
75
+ value: "__select_model__",
76
+ description: "Choose embedding model from OpenRouter",
77
+ });
78
+
79
+ // Dimensions
80
+ options.push({
81
+ label: `📐 Dimensions: ${config.dimensions}`,
82
+ value: "__dimensions__",
83
+ description: "Embedding dimensions (lower = faster, less storage)",
84
+ });
85
+
86
+ // Re-embed
87
+ if (ready && hasModelChanged()) {
88
+ options.push({
89
+ label: "🔄 Re-embed All Memories",
90
+ value: "__reembed__",
91
+ description: "Re-generate all embeddings with current model",
92
+ });
93
+ }
94
+
95
+ // Suppress warning
96
+ if (hasModelChanged() && !config.suppressMigrationWarning) {
97
+ options.push({
98
+ label: "🔕 Suppress Migration Warning",
99
+ value: "__suppress__",
100
+ description: "Hide the model change warning",
101
+ });
102
+ }
103
+
104
+ options.push({
105
+ label: "← Back",
106
+ value: "__exit__",
107
+ description: "Exit settings",
108
+ });
109
+
110
+ const labels = options.map(o => `${o.label} — ${o.description}`);
111
+ const selected = await ui.select(
112
+ "🧠 Memory Settings",
113
+ labels,
114
+ );
115
+ // Map selected label back to value
116
+ const selectedOpt = options.find(o => `${o.label} — ${o.description}` === selected);
117
+ const selectedValue = selectedOpt?.value;
118
+
119
+ if (!selectedValue || selectedValue === "__exit__") {
120
+ running = false;
121
+ continue;
122
+ }
123
+
124
+ switch (selectedValue) {
125
+ case "__add_key__":
126
+ case "__update_key__":
127
+ await handleApiKeyInput(ui);
128
+ break;
129
+ case "__remove_key__":
130
+ clearApiKey();
131
+ ui.notify("API key removed. Vector search disabled.", "info");
132
+ break;
133
+ case "__select_model__":
134
+ await handleModelSelection(ui);
135
+ break;
136
+ case "__dimensions__":
137
+ await handleDimensionsInput(ui);
138
+ break;
139
+ case "__reembed__":
140
+ await handleReembed(ui, ctx);
141
+ break;
142
+ case "__suppress__":
143
+ const cfg = loadEmbeddingConfig();
144
+ cfg.suppressMigrationWarning = true;
145
+ saveEmbeddingConfig(cfg);
146
+ ui.notify("Migration warning suppressed.", "info");
147
+ break;
148
+ }
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Handle API key input.
154
+ */
155
+ async function handleApiKeyInput(ui: ExtensionCommandContext["ui"]): Promise<void> {
156
+ const key = await ui.input(
157
+ "Enter your OpenRouter API key (sk-or-v1-...):",
158
+ "sk-or-v1-...",
159
+ );
160
+
161
+ if (key) {
162
+ const trimmed = key.trim();
163
+ if (trimmed.length === 0) {
164
+ ui.notify("API key cannot be empty.", "warning");
165
+ return;
166
+ }
167
+ if (!trimmed.startsWith("sk-or-") && !trimmed.startsWith("sk-")) {
168
+ ui.notify("Key should start with sk-or- or sk-.", "warning");
169
+ return;
170
+ }
171
+ setApiKey(trimmed);
172
+ ui.notify("API key saved. Vector search enabled.", "info");
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Handle model selection.
178
+ */
179
+ async function handleModelSelection(ui: ExtensionCommandContext["ui"]): Promise<void> {
180
+ const config = loadEmbeddingConfig();
181
+
182
+ const modelOptions = OPENROUTER_EMBEDDING_MODELS.map((m) => ({
183
+ label: `${m.name}${m.id === config.model ? " ✓" : ""}`,
184
+ value: m.id,
185
+ description: `${m.description} (${m.dimensions}d, ~${m.costPer1k}/1k tokens)`,
186
+ }));
187
+
188
+ // Add custom option
189
+ modelOptions.push({
190
+ label: "✏️ Custom Model ID",
191
+ value: "__custom__",
192
+ description: "Enter a custom OpenRouter model ID",
193
+ });
194
+
195
+ const labels = modelOptions.map(o => `${o.label} — ${o.description}`);
196
+ const selected = await ui.select(
197
+ "Select Embedding Model",
198
+ labels,
199
+ );
200
+
201
+ if (!selected) return;
202
+
203
+ // Map label back to value
204
+ const selectedOpt = modelOptions.find(o => `${o.label} — ${o.description}` === selected);
205
+ let modelId = selectedOpt?.value;
206
+
207
+ if (!modelId) return;
208
+
209
+ if (modelId === "__custom__") {
210
+ const custom = await ui.input(
211
+ "Enter the OpenRouter model ID:",
212
+ "openai/text-embedding-3-small",
213
+ );
214
+ if (!custom) return;
215
+ modelId = custom.trim();
216
+ }
217
+
218
+ // Find model info for dimensions
219
+ const modelInfo = OPENROUTER_EMBEDDING_MODELS.find((m) => m.id === modelId);
220
+ const dimensions = modelInfo?.dimensions ?? 384;
221
+
222
+ config.model = modelId;
223
+ config.dimensions = dimensions;
224
+ saveEmbeddingConfig(config);
225
+
226
+ ui.notify(
227
+ `Model set to ${modelId} (${dimensions}d).${hasModelChanged() ? " Re-embed existing memories to use new model." : ""}`,
228
+ "info",
229
+ );
230
+ }
231
+
232
+ /**
233
+ * Handle dimensions input.
234
+ */
235
+ async function handleDimensionsInput(ui: ExtensionCommandContext["ui"]): Promise<void> {
236
+ const config = loadEmbeddingConfig();
237
+
238
+ const dimStr = await ui.input(
239
+ `Enter dimensions (default: 384). Lower = faster, less storage.\nNote: openai/text-embedding-3 supports 256-3072.\nada-002 only supports 1536.`,
240
+ "384",
241
+ );
242
+
243
+ if (dimStr) {
244
+ const dims = parseInt(dimStr, 10);
245
+ if (isNaN(dims) || dims < 64 || dims > 3072) {
246
+ ui.notify("Must be a number between 64 and 3072.", "warning");
247
+ return;
248
+ }
249
+ config.dimensions = dims;
250
+ saveEmbeddingConfig(config);
251
+
252
+ ui.notify(`Dimensions set to ${dims}. Re-embed existing memories to apply.`, "info");
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Handle re-embedding all memories.
258
+ * This is a destructive operation — warns user first.
259
+ */
260
+ async function handleReembed(ui: ExtensionCommandContext["ui"], ctx: ExtensionCommandContext): Promise<void> {
261
+ const confirmOptions = [
262
+ { label: "Yes, re-embed all — Proceed with re-embedding", value: "yes" },
263
+ { label: "Cancel — Abort", value: "no" },
264
+ ];
265
+ const confirmLabels = confirmOptions.map(o => o.label);
266
+ const confirm = await ui.select(
267
+ "Re-embed All Memories",
268
+ confirmLabels,
269
+ );
270
+
271
+ const confirmOpt = confirmOptions.find(o => o.label === confirm);
272
+ if (confirmOpt?.value !== "yes") return;
273
+
274
+ // Import here to avoid circular deps
275
+ const { reembedAllMemories } = await import("../embedding.js");
276
+ const count = await reembedAllMemories(ctx);
277
+
278
+ markModelUsed();
279
+
280
+ ui.notify(`Re-embedded ${count} memories with current model.`, "info");
281
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/milestone",
3
- "version": "2.1.3",
3
+ "version": "2.2.0",
4
4
  "description": "Lifecycle layer for project-level goals — MILESTONES.md tracking, session hooks, auto-sync",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -29,7 +29,7 @@
29
29
  "access": "public"
30
30
  },
31
31
  "dependencies": {
32
- "@pi-unipi/core": "2.1.3"
32
+ "@pi-unipi/core": "2.2.0"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -28,6 +28,8 @@ Notify subscribes to Pi lifecycle events and routes notifications based on your
28
28
  | `agent_settled` | Off | Agent fully settles after retries, compaction, and queued continuations |
29
29
  | `memory_consolidated` | Off | Memory auto-saved |
30
30
  | `session_shutdown` | Off | Session ends |
31
+ | `ask_user_prompt` | Off | Agent asked a question and is waiting for an answer |
32
+ | `permission_request` | Off | A permission prompt is about to be shown (requires [`@gotgenes/pi-permission-system`](https://www.npmjs.com/package/@gotgenes/pi-permission-system)) |
31
33
 
32
34
  Notify registers with the info-screen dashboard, showing enabled platforms and last notification time. The footer subscribes to `NOTIFICATION_SENT` events to display notification stats.
33
35
 
@@ -14,6 +14,7 @@ import { sendGotifyNotification } from "./platforms/gotify.js";
14
14
  import { sendTelegramNotification } from "./platforms/telegram.js";
15
15
  import { sendNtfyNotification } from "./platforms/ntfy.js";
16
16
  import { buildAskUserPromptMessage } from "./ask-user-prompt-message.js";
17
+ import { buildPermissionPromptMessage } from "./permission-prompt-message.js";
17
18
  import { summarizeLastMessage } from "./summarize.js";
18
19
 
19
20
  // Event emitted by @juicesharp/rpiv-ask-user-question before showing its UI.
@@ -21,6 +22,13 @@ import { summarizeLastMessage } from "./summarize.js";
21
22
  // `./events` contract in npm.
22
23
  const ASK_USER_PROMPT_EVENT = "rpiv:ask-user:prompt" as const;
23
24
 
25
+ // Event emitted by @gotgenes/pi-permission-system immediately before the
26
+ // user-facing permission UI is invoked. Fires only for prompts a human must
27
+ // answer — policy auto-allow/deny and session approvals do not emit it.
28
+ // Kept as a local string (like the rpiv event above) because it belongs to a
29
+ // third-party package rather than the unipi event contract.
30
+ const PERMISSION_UI_PROMPT_EVENT = "permissions:ui_prompt" as const;
31
+
24
32
  /** Stored session context for modelRegistry access */
25
33
  let sessionCtx: ExtensionContext | null = null;
26
34
 
@@ -58,6 +66,7 @@ export const BUILTIN_EVENTS: Record<
58
66
  memory_consolidated: { hook: UNIPI_EVENTS.MEMORY_CONSOLIDATED, label: "Memory Saved" },
59
67
  session_shutdown: { hook: "session_shutdown", label: "Session End" },
60
68
  ask_user_prompt: { hook: UNIPI_EVENTS.ASK_USER_PROMPT, label: "Question Asked" },
69
+ permission_request: { hook: PERMISSION_UI_PROMPT_EVENT, label: "Permission Request" },
61
70
  };
62
71
 
63
72
  /**
@@ -298,6 +307,8 @@ function buildEventMessage(eventKey: string, payload: unknown): string {
298
307
  return "Session ending";
299
308
  case "ask_user_prompt":
300
309
  return buildAskUserPromptMessage(payload);
310
+ case "permission_request":
311
+ return buildPermissionPromptMessage(payload);
301
312
  default:
302
313
  return p.message ? String(p.message) : "Event occurred";
303
314
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/notify",
3
- "version": "2.1.3",
3
+ "version": "2.2.0",
4
4
  "description": "Cross-platform notification extension for Pi — native OS, Gotify, and Telegram notifications for agent lifecycle events",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -42,7 +42,7 @@
42
42
  "access": "public"
43
43
  },
44
44
  "dependencies": {
45
- "@pi-unipi/core": "*",
45
+ "@pi-unipi/core": "2.2.0",
46
46
  "node-notifier": "^10.0.1"
47
47
  },
48
48
  "peerDependencies": {
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @pi-unipi/notify — Internal helper: build notification message from
3
+ * permission prompt event payloads.
4
+ *
5
+ * Handles the `permissions:ui_prompt` broadcast emitted by
6
+ * `@gotgenes/pi-permission-system` immediately before a human-facing
7
+ * permission prompt is shown.
8
+ *
9
+ * @internal — not part of the public API. Shared by the event listener and tests.
10
+ */
11
+
12
+ export interface PermissionPromptEventPayload {
13
+ /** Correlation id for the permission request */
14
+ requestId?: string;
15
+ /** Originating subsystem of the request */
16
+ source?: string;
17
+ /** Surface being requested — e.g. "bash", "mcp", "read", "edit" */
18
+ surface?: string | null;
19
+ /** Command / path / tool / skill being requested */
20
+ value?: string | null;
21
+ /** Current or requesting agent, when available */
22
+ agentName?: string | null;
23
+ /** Human-readable permission prompt text */
24
+ message?: string;
25
+ /** Present for forwarded subagent prompts */
26
+ forwarding?: unknown;
27
+ }
28
+
29
+ function isRecord(value: unknown): value is Record<string, unknown> {
30
+ return value !== null && typeof value === "object";
31
+ }
32
+
33
+ /** Return a trimmed non-empty string, or `undefined` for anything else. */
34
+ function cleanString(value: unknown): string | undefined {
35
+ if (typeof value !== "string") return undefined;
36
+ const trimmed = value.trim();
37
+ return trimmed.length > 0 ? trimmed : undefined;
38
+ }
39
+
40
+ /**
41
+ * `forwarding` is documented as "present for forwarded subagent prompts".
42
+ * Treat an explicitly falsy value (false / null / "" / 0) as not forwarded so
43
+ * that publishers which always include the key are handled correctly.
44
+ */
45
+ function isForwarded(value: unknown): boolean {
46
+ if (value === undefined || value === null) return false;
47
+ if (typeof value === "boolean") return value;
48
+ if (typeof value === "string") return value.trim().length > 0;
49
+ if (typeof value === "number") return value !== 0;
50
+ if (Array.isArray(value)) return value.length > 0;
51
+ if (isRecord(value)) return Object.keys(value).length > 0;
52
+ return true;
53
+ }
54
+
55
+ /**
56
+ * Build the "what was requested" clause from `agentName` / `surface` / `value`.
57
+ * Returns `undefined` when there is nothing meaningful to say.
58
+ */
59
+ function buildRequestClause(
60
+ agentName: string | undefined,
61
+ surface: string | undefined,
62
+ value: string | undefined,
63
+ ): string | undefined {
64
+ if (!surface && !value) return undefined;
65
+
66
+ const who = agentName ?? "Agent";
67
+
68
+ if (surface && value) return `${who} requested ${surface} '${value}'.`;
69
+ if (surface) return `${who} requested ${surface} access.`;
70
+ return `${who} requested '${value}'.`;
71
+ }
72
+
73
+ /** Build a human-readable notification message from a permission prompt payload. */
74
+ export function buildPermissionPromptMessage(payload: unknown): string {
75
+ const p = isRecord(payload) ? payload : {};
76
+
77
+ const agentName = cleanString(p.agentName);
78
+ const surface = cleanString(p.surface);
79
+ const value = cleanString(p.value);
80
+ const message = cleanString(p.message);
81
+
82
+ const parts: string[] = [];
83
+
84
+ const requestClause = buildRequestClause(agentName, surface, value);
85
+ if (requestClause) parts.push(requestClause);
86
+
87
+ // Only append the prompt text when it adds information beyond the clause.
88
+ if (message && message !== requestClause) parts.push(message);
89
+
90
+ if (parts.length === 0) parts.push("Pi is waiting for a permission decision.");
91
+
92
+ if (isForwarded(p.forwarding)) parts.push("(forwarded)");
93
+
94
+ return parts.join(" ");
95
+ }
@@ -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
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @pi-unipi/notify — Focus detection abstraction
3
+ *
4
+ * Unified interface for checking whether the terminal window is the
5
+ * foreground (active) window. Platform-specific implementations are
6
+ * dispatched based on process.platform.
7
+ *
8
+ * Currently implemented:
9
+ * - Windows (win32): calls focus-win.ts
10
+ *
11
+ * Unimplemented platforms always return false (no suppression).
12
+ */
13
+
14
+ import { isWindowFocusedOnWindows } from "./focus-win.js";
15
+
16
+ /**
17
+ * Check whether the current terminal/console window is the foreground
18
+ * (active) window. Used by sendNativeNotification to optionally
19
+ * suppress notifications when the user is already looking at the screen.
20
+ *
21
+ * @returns true if the terminal is the foreground window, false otherwise.
22
+ * On unimplemented platforms, always returns false.
23
+ */
24
+ export async function isWindowFocused(): Promise<boolean> {
25
+ switch (process.platform) {
26
+ case "win32":
27
+ return await isWindowFocusedOnWindows();
28
+ // TODO: macOS — use osascript to check frontmost application
29
+ // TODO: Linux — use xdotool (X11) or per-compositor tool (Wayland)
30
+ default:
31
+ return false;
32
+ }
33
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @pi-unipi/notify — Gotify notification platform
3
+ *
4
+ * Sends push notifications to a Gotify server via HTTP POST.
5
+ * Gotify is a self-hosted push notification server.
6
+ */
7
+
8
+ /** Send a notification to Gotify server */
9
+ export async function sendGotifyNotification(
10
+ serverUrl: string,
11
+ appToken: string,
12
+ title: string,
13
+ message: string,
14
+ priority: number = 5
15
+ ): Promise<void> {
16
+ const url = serverUrl.replace(/\/$/, "") + "/message";
17
+ const response = await fetch(url, {
18
+ method: "POST",
19
+ headers: {
20
+ "Content-Type": "application/json",
21
+ "X-Gotify-Key": appToken,
22
+ },
23
+ body: JSON.stringify({
24
+ title,
25
+ message,
26
+ priority,
27
+ }),
28
+ });
29
+
30
+ if (!response.ok) {
31
+ const body = await response.text().catch(() => "<no body>");
32
+ throw new Error(
33
+ `Gotify API error ${response.status}: ${body}`
34
+ );
35
+ }
36
+ }