@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.
- package/README.md +6 -2
- package/package.json +23 -22
- package/packages/ask-user/ask-ui.ts +45 -31
- package/packages/ask-user/launcher-ui.ts +26 -22
- package/packages/ask-user/package.json +2 -2
- package/packages/ask-user/settings-tui.ts +22 -9
- package/packages/autocomplete/package.json +1 -1
- package/packages/autocomplete/src/constants.ts +5 -0
- package/packages/btw/package.json +2 -2
- package/packages/cocoindex/package.json +2 -2
- package/packages/compactor/package.json +3 -3
- package/packages/compactor/src/tui/settings-overlay.ts +2 -1
- package/packages/core/constants.ts +18 -0
- package/packages/core/index.ts +1 -0
- package/packages/core/package.json +1 -1
- package/packages/core/tui-width.ts +127 -0
- package/packages/footer/package.json +2 -2
- package/packages/footer/src/tui/settings-tui.ts +2 -1
- package/packages/image/README.md +82 -0
- package/packages/image/index.ts +1 -0
- package/packages/image/package.json +57 -0
- package/packages/image/skills/image/SKILL.md +73 -0
- package/packages/image/src/commands.ts +17 -0
- package/packages/image/src/generate.ts +201 -0
- package/packages/image/src/image-source.ts +204 -0
- package/packages/image/src/index.ts +99 -0
- package/packages/image/src/models.ts +290 -0
- package/packages/image/src/recognize.ts +223 -0
- package/packages/image/src/settings.ts +149 -0
- package/packages/image/src/tools.ts +296 -0
- package/packages/image/src/tui/model-selector.ts +279 -0
- package/packages/image/src/tui/settings-dialog.ts +236 -0
- package/packages/info-screen/package.json +2 -2
- package/packages/info-screen/tui/info-overlay.ts +505 -0
- package/packages/input-shortcuts/package.json +2 -2
- package/packages/kanboard/package.json +2 -2
- package/packages/kanboard/tui/kanboard-overlay.ts +300 -0
- package/packages/mcp/package.json +2 -2
- package/packages/mcp/src/tui/add-overlay.ts +10 -7
- package/packages/mcp/src/tui/settings-overlay.ts +10 -7
- package/packages/memory/package.json +3 -3
- package/packages/memory/tui/settings-tui.ts +281 -0
- package/packages/milestone/package.json +2 -2
- package/packages/notify/README.md +2 -0
- package/packages/notify/events.ts +11 -0
- package/packages/notify/package.json +2 -2
- package/packages/notify/permission-prompt-message.ts +95 -0
- package/packages/notify/platforms/focus-win.ts +123 -0
- package/packages/notify/platforms/focus.ts +33 -0
- package/packages/notify/platforms/gotify.ts +36 -0
- package/packages/notify/platforms/native.ts +79 -0
- package/packages/notify/platforms/node-notifier.d.ts +20 -0
- package/packages/notify/platforms/ntfy.ts +48 -0
- package/packages/notify/platforms/telegram.ts +77 -0
- package/packages/notify/settings.ts +1 -0
- package/packages/notify/skills/configure-notify/SKILL.md +28 -1
- package/packages/notify/src/__tests__/permission-prompt-message.test.ts +172 -0
- package/packages/notify/tui/gotify-setup.ts +529 -0
- package/packages/notify/tui/ntfy-setup.ts +653 -0
- package/packages/notify/tui/recap-model-selector.ts +301 -0
- package/packages/notify/tui/settings-overlay.ts +353 -0
- package/packages/notify/tui/telegram-setup.ts +302 -0
- package/packages/ralph/package.json +3 -3
- package/packages/subagents/package.json +4 -4
- package/packages/subagents/src/__tests__/shutdown-stale-ctx.test.ts +185 -0
- package/packages/subagents/src/index.ts +52 -10
- package/packages/unipi/index.ts +2 -0
- package/packages/updater/package.json +2 -2
- package/packages/updater/src/tui/changelog-overlay.ts +2 -2
- package/packages/updater/src/tui/readme-overlay.ts +2 -1
- package/packages/updater/src/tui/update-overlay.ts +2 -1
- package/packages/utility/package.json +2 -2
- package/packages/utility/src/tui/badge-settings-tui.ts +2 -2
- package/packages/utility/src/tui/util-settings-tui.ts +2 -2
- package/packages/web-api/README.md +28 -11
- package/packages/web-api/package.json +8 -2
- package/packages/web-api/skills/web/SKILL.md +36 -9
- package/packages/web-api/src/index.ts +15 -0
- package/packages/web-api/src/providers/duckduckgo.ts +71 -27
- package/packages/web-api/src/providers/firecrawl.ts +1 -1
- package/packages/web-api/src/providers/jina-reader.ts +1 -1
- package/packages/web-api/src/providers/jina-search.ts +1 -1
- package/packages/web-api/src/providers/perplexity.ts +2 -2
- package/packages/web-api/src/providers/serpapi.ts +1 -1
- package/packages/web-api/src/providers/tavily.ts +1 -1
- package/packages/web-api/src/providers/wigolo-client.ts +196 -0
- package/packages/web-api/src/providers/wigolo.ts +117 -0
- package/packages/web-api/src/settings.ts +1 -0
- package/packages/web-api/src/tools.ts +93 -36
- package/packages/workflow/package.json +2 -2
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/notify — Telegram Setup TUI Component
|
|
3
|
+
*
|
|
4
|
+
* Interactive overlay for setting up Telegram bot notifications.
|
|
5
|
+
* Guides user through BotFather flow and auto-detects chat ID.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
9
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { pollForChatId } from "../platforms/telegram.js";
|
|
12
|
+
import { updateConfig } from "../settings.js";
|
|
13
|
+
import { boxInnerWidth } from "@pi-unipi/core";
|
|
14
|
+
|
|
15
|
+
type SetupPhase = "instructions" | "token" | "polling" | "success" | "error" | "timeout";
|
|
16
|
+
|
|
17
|
+
/** Spinner frames */
|
|
18
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Telegram setup overlay component.
|
|
22
|
+
*/
|
|
23
|
+
export class TelegramSetupOverlay implements Component {
|
|
24
|
+
private phase: SetupPhase = "instructions";
|
|
25
|
+
private botToken = "";
|
|
26
|
+
private chatId: string | null = null;
|
|
27
|
+
private error: string | null = null;
|
|
28
|
+
private spinnerFrame = 0;
|
|
29
|
+
private spinnerTimer: ReturnType<typeof setInterval> | null = null;
|
|
30
|
+
private pollAbort: AbortController | null = null;
|
|
31
|
+
private pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
32
|
+
private startTime = Date.now();
|
|
33
|
+
private readonly TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
34
|
+
private isInPaste = false;
|
|
35
|
+
private pasteBuffer = "";
|
|
36
|
+
onClose?: () => void;
|
|
37
|
+
requestRender?: () => void;
|
|
38
|
+
private theme: Theme | null = null;
|
|
39
|
+
|
|
40
|
+
setTheme(theme: Theme): void {
|
|
41
|
+
this.theme = theme;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
invalidate(): void {}
|
|
45
|
+
|
|
46
|
+
handleInput(data: string): void {
|
|
47
|
+
switch (this.phase) {
|
|
48
|
+
case "instructions":
|
|
49
|
+
if (data === "\r" || data === " ") {
|
|
50
|
+
this.phase = "token";
|
|
51
|
+
} else if (data === "\x1b") {
|
|
52
|
+
this.cleanup();
|
|
53
|
+
this.onClose?.();
|
|
54
|
+
}
|
|
55
|
+
break;
|
|
56
|
+
case "token":
|
|
57
|
+
// Handle bracketed paste mode
|
|
58
|
+
if (this.isInPaste) {
|
|
59
|
+
this.pasteBuffer += data;
|
|
60
|
+
const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
|
|
61
|
+
if (endIndex !== -1) {
|
|
62
|
+
// Extract pasted content and process it
|
|
63
|
+
const pasteContent = this.pasteBuffer.substring(0, endIndex);
|
|
64
|
+
this.processTokenInput(pasteContent);
|
|
65
|
+
this.isInPaste = false;
|
|
66
|
+
this.pasteBuffer = "";
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// Detect start of bracketed paste
|
|
71
|
+
if (data.includes("\x1b[200~")) {
|
|
72
|
+
this.isInPaste = true;
|
|
73
|
+
this.pasteBuffer = data.replace("\x1b[200~", "");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (data === "\r" && this.botToken.length > 0) {
|
|
77
|
+
this.startPolling();
|
|
78
|
+
} else if (data === "\x1b") {
|
|
79
|
+
this.cleanup();
|
|
80
|
+
this.onClose?.();
|
|
81
|
+
} else if (data === "\x7f" || data === "\b") {
|
|
82
|
+
this.botToken = this.botToken.slice(0, -1);
|
|
83
|
+
} else {
|
|
84
|
+
this.processTokenInput(data);
|
|
85
|
+
}
|
|
86
|
+
break;
|
|
87
|
+
case "polling":
|
|
88
|
+
if (data === "\x1b") {
|
|
89
|
+
this.cleanup();
|
|
90
|
+
this.onClose?.();
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
93
|
+
case "success":
|
|
94
|
+
case "error":
|
|
95
|
+
case "timeout":
|
|
96
|
+
if (data === "\r" || data === " " || data === "\x1b") {
|
|
97
|
+
this.cleanup();
|
|
98
|
+
this.onClose?.();
|
|
99
|
+
}
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private processTokenInput(data: string): void {
|
|
105
|
+
// Ignore escape sequences (arrow keys, function keys, etc.)
|
|
106
|
+
if (data.startsWith("\x1b[")) return;
|
|
107
|
+
// Filter to valid bot token characters only
|
|
108
|
+
const cleaned = data.replace(/[^0-9:A-Za-z_-]/g, "");
|
|
109
|
+
if (cleaned.length > 0) {
|
|
110
|
+
this.botToken += cleaned;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private startPolling(): void {
|
|
115
|
+
this.phase = "polling";
|
|
116
|
+
this.startTime = Date.now();
|
|
117
|
+
this.pollAbort = new AbortController();
|
|
118
|
+
|
|
119
|
+
// Start spinner animation
|
|
120
|
+
this.spinnerTimer = setInterval(() => {
|
|
121
|
+
this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
122
|
+
this.requestRender?.();
|
|
123
|
+
}, 80);
|
|
124
|
+
|
|
125
|
+
// Start polling
|
|
126
|
+
this.doPoll();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private async doPoll(): Promise<void> {
|
|
130
|
+
if (this.phase !== "polling" || !this.pollAbort) return;
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const chatId = await pollForChatId(
|
|
134
|
+
this.botToken,
|
|
135
|
+
this.pollAbort.signal
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
if (chatId) {
|
|
139
|
+
this.chatId = chatId;
|
|
140
|
+
this.phase = "success";
|
|
141
|
+
this.saveConfig();
|
|
142
|
+
this.cleanup();
|
|
143
|
+
this.requestRender?.();
|
|
144
|
+
// Auto-close after brief delay to show success
|
|
145
|
+
setTimeout(() => this.onClose?.(), 1000);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Check timeout
|
|
150
|
+
if (Date.now() - this.startTime > this.TIMEOUT_MS) {
|
|
151
|
+
this.phase = "timeout";
|
|
152
|
+
this.error = "Timed out after 5 minutes";
|
|
153
|
+
this.cleanup();
|
|
154
|
+
this.requestRender?.();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Schedule next poll
|
|
159
|
+
this.pollTimer = setTimeout(() => this.doPoll(), 2000);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
if (this.pollAbort?.signal.aborted) return;
|
|
162
|
+
this.phase = "error";
|
|
163
|
+
this.error = err instanceof Error ? err.message : String(err);
|
|
164
|
+
this.cleanup();
|
|
165
|
+
this.requestRender?.();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private saveConfig(): void {
|
|
170
|
+
if (this.botToken && this.chatId) {
|
|
171
|
+
updateConfig({
|
|
172
|
+
telegram: {
|
|
173
|
+
enabled: true,
|
|
174
|
+
botToken: this.botToken,
|
|
175
|
+
chatId: this.chatId,
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private cleanup(): void {
|
|
182
|
+
if (this.spinnerTimer) {
|
|
183
|
+
clearInterval(this.spinnerTimer);
|
|
184
|
+
this.spinnerTimer = null;
|
|
185
|
+
}
|
|
186
|
+
if (this.pollTimer) {
|
|
187
|
+
clearTimeout(this.pollTimer);
|
|
188
|
+
this.pollTimer = null;
|
|
189
|
+
}
|
|
190
|
+
if (this.pollAbort) {
|
|
191
|
+
this.pollAbort.abort();
|
|
192
|
+
this.pollAbort = null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ─── Theme helpers ───────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
private fg(color: string, text: string): string {
|
|
199
|
+
if (this.theme) return this.theme.fg(color as any, text);
|
|
200
|
+
const c: Record<string, string> = {
|
|
201
|
+
accent: "\x1b[36m", success: "\x1b[32m", warning: "\x1b[33m",
|
|
202
|
+
error: "\x1b[31m", dim: "\x1b[2m", borderMuted: "\x1b[90m",
|
|
203
|
+
};
|
|
204
|
+
return `${c[color] ?? ""}${text}\x1b[0m`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private bold(text: string): string {
|
|
208
|
+
return this.theme ? this.theme.bold(text) : `\x1b[1m${text}\x1b[0m`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private frameLine(content: string, innerWidth: number): string {
|
|
212
|
+
const truncated = truncateToWidth(content, innerWidth, "");
|
|
213
|
+
const padding = Math.max(0, innerWidth - visibleWidth(truncated));
|
|
214
|
+
return `${this.fg("borderMuted", "│")}${truncated}${" ".repeat(padding)}${this.fg("borderMuted", "│")}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private ruleLine(innerWidth: number): string {
|
|
218
|
+
return this.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
|
|
222
|
+
const left = edge === "top" ? "┌" : "└";
|
|
223
|
+
const right = edge === "top" ? "┐" : "┘";
|
|
224
|
+
return this.fg("borderMuted", `${left}${"─".repeat(innerWidth)}${right}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
render(width: number): string[] {
|
|
228
|
+
const innerWidth = boxInnerWidth(width);
|
|
229
|
+
const lines: string[] = [];
|
|
230
|
+
|
|
231
|
+
lines.push(this.borderLine(innerWidth, "top"));
|
|
232
|
+
lines.push(this.frameLine(this.fg("accent", this.bold("🤖 Telegram Bot Setup")), innerWidth));
|
|
233
|
+
lines.push(this.ruleLine(innerWidth));
|
|
234
|
+
|
|
235
|
+
switch (this.phase) {
|
|
236
|
+
case "instructions":
|
|
237
|
+
lines.push(this.frameLine(this.fg("dim", "Set up Telegram notifications in 3 steps:"), innerWidth));
|
|
238
|
+
lines.push(this.frameLine("", innerWidth));
|
|
239
|
+
lines.push(this.frameLine(` ${this.bold("1.")} Open Telegram and message ${this.fg("accent", "@BotFather")}`, innerWidth));
|
|
240
|
+
lines.push(this.frameLine(` Send /newbot and follow the prompts to create a bot`, innerWidth));
|
|
241
|
+
lines.push(this.frameLine("", innerWidth));
|
|
242
|
+
lines.push(this.frameLine(` ${this.bold("2.")} Copy the bot token from BotFather`, innerWidth));
|
|
243
|
+
lines.push(this.frameLine("", innerWidth));
|
|
244
|
+
lines.push(this.frameLine(` ${this.bold("3.")} Send any message to your new bot`, innerWidth));
|
|
245
|
+
lines.push(this.frameLine(` (We'll detect your chat ID automatically)`, innerWidth));
|
|
246
|
+
lines.push(this.ruleLine(innerWidth));
|
|
247
|
+
lines.push(this.frameLine(this.fg("dim", "Press Enter to continue, Esc to cancel"), innerWidth));
|
|
248
|
+
break;
|
|
249
|
+
|
|
250
|
+
case "token":
|
|
251
|
+
lines.push(this.frameLine(this.fg("dim", "Paste your bot token from BotFather:"), innerWidth));
|
|
252
|
+
lines.push(this.frameLine("", innerWidth));
|
|
253
|
+
const display = this.fg("accent", this.bold(this.botToken || " "));
|
|
254
|
+
lines.push(this.frameLine(` ${display}${this.fg("dim", "█")}`, innerWidth));
|
|
255
|
+
lines.push(this.frameLine("", innerWidth));
|
|
256
|
+
lines.push(this.frameLine(this.fg("dim", "Enter to start polling · Esc to cancel"), innerWidth));
|
|
257
|
+
break;
|
|
258
|
+
|
|
259
|
+
case "polling": {
|
|
260
|
+
const frame = SPINNER_FRAMES[this.spinnerFrame] || "⠋";
|
|
261
|
+
const elapsed = Math.floor((Date.now() - this.startTime) / 1000);
|
|
262
|
+
const remaining = Math.max(0, 300 - elapsed);
|
|
263
|
+
lines.push(this.frameLine(` ${this.fg("accent", frame)} ${this.bold("Waiting for first message...")}`, innerWidth));
|
|
264
|
+
lines.push(this.frameLine("", innerWidth));
|
|
265
|
+
lines.push(this.frameLine(` ${this.fg("dim", "Send any message to your bot in Telegram")}`, innerWidth));
|
|
266
|
+
lines.push(this.frameLine(` ${this.fg("dim", `Timeout: ${Math.floor(remaining / 60)}:${String(remaining % 60).padStart(2, "0")}`)}`, innerWidth));
|
|
267
|
+
lines.push(this.ruleLine(innerWidth));
|
|
268
|
+
lines.push(this.frameLine(this.fg("dim", "Esc to cancel"), innerWidth));
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
case "success":
|
|
273
|
+
lines.push(this.frameLine(` ${this.fg("success", "✓ Telegram bot configured!")}`, innerWidth));
|
|
274
|
+
lines.push(this.frameLine("", innerWidth));
|
|
275
|
+
lines.push(this.frameLine(` ${this.fg("dim", `Chat ID: ${this.chatId}`)}`, innerWidth));
|
|
276
|
+
lines.push(this.frameLine(` ${this.fg("dim", "Notifications will be sent to this chat")}`, innerWidth));
|
|
277
|
+
lines.push(this.ruleLine(innerWidth));
|
|
278
|
+
lines.push(this.frameLine(this.fg("dim", "Press Enter to close"), innerWidth));
|
|
279
|
+
break;
|
|
280
|
+
|
|
281
|
+
case "error":
|
|
282
|
+
lines.push(this.frameLine(` ${this.fg("error", "✗ Setup failed")}`, innerWidth));
|
|
283
|
+
lines.push(this.frameLine("", innerWidth));
|
|
284
|
+
lines.push(this.frameLine(` ${this.fg("dim", this.error || "Unknown error")}`, innerWidth));
|
|
285
|
+
lines.push(this.ruleLine(innerWidth));
|
|
286
|
+
lines.push(this.frameLine(this.fg("dim", "Press Enter to close"), innerWidth));
|
|
287
|
+
break;
|
|
288
|
+
|
|
289
|
+
case "timeout":
|
|
290
|
+
lines.push(this.frameLine(` ${this.fg("warning", "⏰ Timed out after 5 minutes")}`, innerWidth));
|
|
291
|
+
lines.push(this.frameLine("", innerWidth));
|
|
292
|
+
lines.push(this.frameLine(` ${this.fg("dim", "Make sure you sent a message to your bot in Telegram")}`, innerWidth));
|
|
293
|
+
lines.push(this.frameLine(` ${this.fg("dim", "You can try again with /unipi:notify-set-tg")}`, innerWidth));
|
|
294
|
+
lines.push(this.ruleLine(innerWidth));
|
|
295
|
+
lines.push(this.frameLine(this.fg("dim", "Press Enter to close"), innerWidth));
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
lines.push(this.borderLine(innerWidth, "bottom"));
|
|
300
|
+
return lines;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/ralph",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Long-running iterative development loops for Pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
"access": "public"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@pi-unipi/core": "2.
|
|
31
|
-
"@pi-unipi/info-screen": "2.
|
|
30
|
+
"@pi-unipi/core": "2.2.0",
|
|
31
|
+
"@pi-unipi/info-screen": "2.2.0"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
34
|
"@earendil-works/pi-ai": "^0.80.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/subagents",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Subagents for UniPi — parallel execution, file locking, workflow integration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
"test": "npx tsx --test src/__tests__/*.test.ts"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@pi-unipi/core": "2.
|
|
21
|
-
"@pi-unipi/workflow": "2.
|
|
22
|
-
"@pi-unipi/info-screen": "2.
|
|
20
|
+
"@pi-unipi/core": "2.2.0",
|
|
21
|
+
"@pi-unipi/workflow": "2.2.0",
|
|
22
|
+
"@pi-unipi/info-screen": "2.2.0",
|
|
23
23
|
"@earendil-works/pi-agent-core": "^0.80.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: background agents must not touch a stale extension runtime
|
|
3
|
+
*
|
|
4
|
+
* BUG — Pi crashed on exit with:
|
|
5
|
+
* "This extension ctx is stale after session replacement or reload."
|
|
6
|
+
* at pi.sendMessage (loader.js:240)
|
|
7
|
+
* at onComplete (packages/subagents/src/index.ts)
|
|
8
|
+
*
|
|
9
|
+
* Sequence:
|
|
10
|
+
* 1. A background agent (e.g. the session-name generator) is still running.
|
|
11
|
+
* 2. The user quits. Pi emits `session_shutdown`, then immediately calls
|
|
12
|
+
* `AgentSession.dispose()`, which invalidates the extension runtime.
|
|
13
|
+
* 3. Our handler calls `abortAll()`, but aborting only *signals* the
|
|
14
|
+
* AbortController — the in-flight promise still settles on a later
|
|
15
|
+
* microtask and invokes the completion callback.
|
|
16
|
+
* 4. That callback called `pi.sendMessage()`, which is `assertActive`-gated,
|
|
17
|
+
* so it threw from an async continuation with no catch → process crash.
|
|
18
|
+
*
|
|
19
|
+
* FIX — a `sessionEnded` flag set at the top of the `session_shutdown` handler
|
|
20
|
+
* (before `abortAll()`), checked at the top of the completion callback, plus a
|
|
21
|
+
* try/catch around `sendMessage` for the session-replacement race.
|
|
22
|
+
*
|
|
23
|
+
* The flag is scoped to the extension factory, not the module: `/new`,
|
|
24
|
+
* `/fork` and `/resume` also emit `session_shutdown` and pi re-invokes the
|
|
25
|
+
* factory for the replacement session, so a fresh closure resets it.
|
|
26
|
+
* (Verified against pi 0.80.2: `/new` emits `shutdown reason=new`, then the
|
|
27
|
+
* factory runs again with `ended=false`.)
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it } from "node:test";
|
|
31
|
+
import assert from "node:assert/strict";
|
|
32
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
33
|
+
import { join } from "node:path";
|
|
34
|
+
|
|
35
|
+
const ROOT = join(import.meta.dirname, "../../../..");
|
|
36
|
+
|
|
37
|
+
function readSource(relativePath: string): string {
|
|
38
|
+
const fullPath = join(ROOT, relativePath);
|
|
39
|
+
if (!existsSync(fullPath)) throw new Error(`File not found: ${fullPath}`);
|
|
40
|
+
return readFileSync(fullPath, "utf-8");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── Behavioural: reproduce the race ────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Minimal stand-in for pi's runtime: every `assertActive`-gated method throws
|
|
47
|
+
* once the session has been disposed.
|
|
48
|
+
*/
|
|
49
|
+
function createFakeRuntime() {
|
|
50
|
+
let stale = false;
|
|
51
|
+
return {
|
|
52
|
+
invalidate() {
|
|
53
|
+
stale = true;
|
|
54
|
+
},
|
|
55
|
+
sendMessage() {
|
|
56
|
+
if (stale) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
"This extension ctx is stale after session replacement or reload.",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return { delivered: true };
|
|
62
|
+
},
|
|
63
|
+
setSessionName() {
|
|
64
|
+
if (stale) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
"This extension ctx is stale after session replacement or reload.",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
// pi.events is NOT assertActive-gated — it keeps working.
|
|
71
|
+
events: { emit() {} },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reproduces the extension's shutdown wiring.
|
|
77
|
+
*
|
|
78
|
+
* @param guard - whether to apply the `sessionEnded` fix
|
|
79
|
+
*/
|
|
80
|
+
function simulateShutdownRace(guard: boolean) {
|
|
81
|
+
const pi = createFakeRuntime();
|
|
82
|
+
let sessionEnded = false;
|
|
83
|
+
const errors: Error[] = [];
|
|
84
|
+
|
|
85
|
+
// The completion callback registered with AgentManager.
|
|
86
|
+
const onComplete = () => {
|
|
87
|
+
if (guard && sessionEnded) return;
|
|
88
|
+
pi.sendMessage();
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// A background agent whose promise is still pending at shutdown.
|
|
92
|
+
let settle!: () => void;
|
|
93
|
+
const inFlight = new Promise<void>((resolve) => {
|
|
94
|
+
settle = resolve;
|
|
95
|
+
}).then(() => {
|
|
96
|
+
try {
|
|
97
|
+
onComplete();
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// In production this is an unhandled rejection that kills the process.
|
|
100
|
+
errors.push(error as Error);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// --- session_shutdown handler ---
|
|
105
|
+
if (guard) sessionEnded = true; // must be set BEFORE abortAll()
|
|
106
|
+
settle(); // abortAll(): signals abort; the promise settles a tick later
|
|
107
|
+
// --- pi disposes the session right after handlers resolve ---
|
|
108
|
+
pi.invalidate();
|
|
109
|
+
|
|
110
|
+
return { done: inFlight, errors };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
describe("shutdown race — background agent completing after dispose", () => {
|
|
114
|
+
it("reproduces the stale-ctx crash without the guard", async () => {
|
|
115
|
+
const { done, errors } = simulateShutdownRace(false);
|
|
116
|
+
await done;
|
|
117
|
+
|
|
118
|
+
assert.equal(errors.length, 1, "expected the unguarded path to throw");
|
|
119
|
+
assert.match(errors[0].message, /stale after session replacement/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("does not touch the stale runtime with the guard", async () => {
|
|
123
|
+
const { done, errors } = simulateShutdownRace(true);
|
|
124
|
+
await done;
|
|
125
|
+
|
|
126
|
+
assert.deepEqual(errors, [], "guarded path must not throw");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ─── Source contract: the wiring must stay correct ──────────────────
|
|
131
|
+
|
|
132
|
+
describe("subagents shutdown wiring", () => {
|
|
133
|
+
const src = readSource("packages/subagents/src/index.ts");
|
|
134
|
+
|
|
135
|
+
it("declares the sessionEnded guard", () => {
|
|
136
|
+
assert.match(
|
|
137
|
+
src,
|
|
138
|
+
/let sessionEnded = false;/,
|
|
139
|
+
"expected a `sessionEnded` guard flag",
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("sets the guard before aborting, inside session_shutdown", () => {
|
|
144
|
+
const handler = src.match(
|
|
145
|
+
/pi\.on\(\s*"session_shutdown"[\s\S]*?\}\s*\);/,
|
|
146
|
+
)?.[0];
|
|
147
|
+
assert.ok(handler, "session_shutdown handler not found");
|
|
148
|
+
|
|
149
|
+
const guardIdx = handler.indexOf("sessionEnded = true");
|
|
150
|
+
const abortIdx = handler.indexOf("abortAll()");
|
|
151
|
+
|
|
152
|
+
assert.notEqual(guardIdx, -1, "handler must set sessionEnded = true");
|
|
153
|
+
assert.notEqual(abortIdx, -1, "handler must still call abortAll()");
|
|
154
|
+
assert.ok(
|
|
155
|
+
guardIdx < abortIdx,
|
|
156
|
+
"sessionEnded must be set BEFORE abortAll(), which settles in-flight promises",
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("checks the guard before any pi call in the completion callback", () => {
|
|
161
|
+
const guardIdx = src.indexOf("if (sessionEnded) return;");
|
|
162
|
+
assert.notEqual(guardIdx, -1, "completion callback must bail when sessionEnded");
|
|
163
|
+
|
|
164
|
+
const sendIdx = src.indexOf("pi.sendMessage");
|
|
165
|
+
assert.notEqual(sendIdx, -1, "expected a pi.sendMessage call");
|
|
166
|
+
assert.ok(
|
|
167
|
+
guardIdx < sendIdx,
|
|
168
|
+
"the guard must be checked before pi.sendMessage is reached",
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("wraps sendMessage in try/catch for the session-replacement race", () => {
|
|
173
|
+
const block = src.match(/try\s*\{\s*pi\.sendMessage[\s\S]*?\}\s*catch\s*\{[\s\S]*?\}/);
|
|
174
|
+
assert.ok(
|
|
175
|
+
block,
|
|
176
|
+
"pi.sendMessage must be wrapped in try/catch — a notification is best-effort",
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("keeps setSessionName defensively wrapped", () => {
|
|
181
|
+
// Badge generation also runs on a background agent.
|
|
182
|
+
const block = src.match(/try\s*\{\s*pi\.setSessionName[\s\S]*?\}\s*catch/);
|
|
183
|
+
assert.ok(block, "pi.setSessionName must stay wrapped in try/catch");
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -137,10 +137,40 @@ export default function (pi: ExtensionAPI) {
|
|
|
137
137
|
// Activity tracking for widget
|
|
138
138
|
const agentActivity = new Map<string, AgentActivity>();
|
|
139
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Set once `session_shutdown` fires — after which `pi` must not be touched.
|
|
142
|
+
*
|
|
143
|
+
* Pi disposes the session as soon as the shutdown handlers resolve, and
|
|
144
|
+
* `AgentSession.dispose()` invalidates the extension runtime. Every
|
|
145
|
+
* `assertActive`-gated method (`sendMessage`, `setSessionName`,
|
|
146
|
+
* `appendEntry`, `setModel`, …) then throws "This extension ctx is stale
|
|
147
|
+
* after session replacement or reload".
|
|
148
|
+
*
|
|
149
|
+
* Background agents outlive that moment: `abortAll()` only signals their
|
|
150
|
+
* AbortController, so the in-flight promise settles a microtask *later* and
|
|
151
|
+
* fires this completion callback against a dead runtime — an unhandled
|
|
152
|
+
* throw that crashed the process on exit.
|
|
153
|
+
*
|
|
154
|
+
* Scoped to the extension factory rather than module scope on purpose:
|
|
155
|
+
* `session_shutdown` also fires for `/new`, `/fork` and `/resume` (reasons
|
|
156
|
+
* "new" / "fork" / "resume"), and pi re-invokes the extension factory for
|
|
157
|
+
* the replacement session. A fresh closure therefore starts with
|
|
158
|
+
* `sessionEnded = false`, so the guard can never latch permanently.
|
|
159
|
+
* Verified: `/new` emits `shutdown reason=new` then re-runs the factory.
|
|
160
|
+
*
|
|
161
|
+
* `pi.events` is NOT gated, so cross-module events still fire.
|
|
162
|
+
*/
|
|
163
|
+
let sessionEnded = false;
|
|
164
|
+
|
|
140
165
|
// Create manager with completion callback
|
|
141
166
|
const manager = new AgentManager(
|
|
142
167
|
(record) => {
|
|
143
168
|
agentActivity.delete(record.id);
|
|
169
|
+
|
|
170
|
+
// After shutdown the UI is gone and the runtime is stale — nothing here
|
|
171
|
+
// is deliverable, and touching `pi` would throw.
|
|
172
|
+
if (sessionEnded) return;
|
|
173
|
+
|
|
144
174
|
widget.markFinished(record.id);
|
|
145
175
|
widget.update();
|
|
146
176
|
|
|
@@ -160,6 +190,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
160
190
|
record.resultConsumed = true;
|
|
161
191
|
}
|
|
162
192
|
|
|
193
|
+
|
|
163
194
|
// Send styled notification via message renderer
|
|
164
195
|
const status = getStatusLabel(record.status, record.error);
|
|
165
196
|
const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
|
|
@@ -180,15 +211,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
180
211
|
].join("\n");
|
|
181
212
|
|
|
182
213
|
if (!record.resultConsumed) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
214
|
+
// Defence in depth: `sessionEnded` covers the ordinary shutdown path,
|
|
215
|
+
// but a session can also be replaced mid-flight. Delivering a
|
|
216
|
+
// notification is best-effort — it must never take the process down.
|
|
217
|
+
try {
|
|
218
|
+
pi.sendMessage<NotificationDetails>(
|
|
219
|
+
{
|
|
220
|
+
customType: "subagent-notification",
|
|
221
|
+
content: notificationXml,
|
|
222
|
+
display: true,
|
|
223
|
+
details,
|
|
224
|
+
},
|
|
225
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
226
|
+
);
|
|
227
|
+
} catch {
|
|
228
|
+
// Runtime went stale between the guard and here — nothing to notify.
|
|
229
|
+
}
|
|
192
230
|
}
|
|
193
231
|
|
|
194
232
|
pi.events.emit("subagents:completed", {
|
|
@@ -394,8 +432,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
394
432
|
});
|
|
395
433
|
});
|
|
396
434
|
|
|
397
|
-
// ESC propagation: abort all agents on session shutdown
|
|
435
|
+
// ESC propagation: abort all agents on session shutdown.
|
|
436
|
+
// Set the guard FIRST: abortAll() settles in-flight promises, whose
|
|
437
|
+
// completion callbacks would otherwise reach a runtime that pi is about to
|
|
438
|
+
// invalidate.
|
|
398
439
|
pi.on("session_shutdown", async () => {
|
|
440
|
+
sessionEnded = true;
|
|
399
441
|
manager.abortAll();
|
|
400
442
|
manager.dispose();
|
|
401
443
|
});
|
package/packages/unipi/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ import footer from "@pi-unipi/footer";
|
|
|
30
30
|
import updater from "@pi-unipi/updater";
|
|
31
31
|
import inputShortcuts from "@pi-unipi/input-shortcuts";
|
|
32
32
|
import cocoindex from "@pi-unipi/cocoindex";
|
|
33
|
+
import image from "../image/src/index.js";
|
|
33
34
|
|
|
34
35
|
export default function (pi: ExtensionAPI) {
|
|
35
36
|
workflow(pi);
|
|
@@ -51,4 +52,5 @@ export default function (pi: ExtensionAPI) {
|
|
|
51
52
|
updater(pi);
|
|
52
53
|
inputShortcuts(pi);
|
|
53
54
|
cocoindex(pi);
|
|
55
|
+
image(pi);
|
|
54
56
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/updater",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Auto-updater, changelog browser, and readme browser for Unipi — checks npm registry, renders CHANGELOG.md and README.md files in TUI overlays",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"access": "public"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@pi-unipi/core": "2.
|
|
36
|
+
"@pi-unipi/core": "2.2.0"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@earendil-works/pi-coding-agent": "^0.80.0",
|
|
@@ -11,7 +11,7 @@ import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/
|
|
|
11
11
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { parseChangelog } from "../changelog.js";
|
|
13
13
|
import { renderMarkdown } from "../markdown.js";
|
|
14
|
-
import { getInstalledPackageVersion } from "@pi-unipi/core";
|
|
14
|
+
import { getInstalledPackageVersion, boxInnerWidth } from "@pi-unipi/core";
|
|
15
15
|
import type { ChangelogEntry } from "../../types.js";
|
|
16
16
|
|
|
17
17
|
type View = "list" | "detail";
|
|
@@ -72,7 +72,7 @@ export function renderChangelogOverlay() {
|
|
|
72
72
|
const render = (width: number): string[] => {
|
|
73
73
|
ensureLoaded();
|
|
74
74
|
|
|
75
|
-
const innerWidth =
|
|
75
|
+
const innerWidth = boxInnerWidth(width);
|
|
76
76
|
const lines: string[] = [];
|
|
77
77
|
|
|
78
78
|
// ── Header ──────────────────────────────────────────────────────
|