@pi-unipi/unipi 2.1.2 → 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 (82) hide show
  1. package/README.md +6 -2
  2. package/package.json +24 -23
  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 +3 -3
  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 +4 -4
  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 +3 -3
  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 +3 -2
  35. package/packages/input-shortcuts/package.json +3 -3
  36. package/packages/kanboard/package.json +2 -2
  37. package/packages/mcp/package.json +2 -2
  38. package/packages/mcp/src/tui/add-overlay.ts +10 -7
  39. package/packages/mcp/src/tui/settings-overlay.ts +10 -7
  40. package/packages/memory/package.json +3 -3
  41. package/packages/milestone/package.json +2 -2
  42. package/packages/notify/README.md +4 -1
  43. package/packages/notify/events.ts +130 -81
  44. package/packages/notify/package.json +3 -3
  45. package/packages/notify/permission-prompt-message.ts +95 -0
  46. package/packages/notify/settings.ts +2 -0
  47. package/packages/notify/skills/configure-notify/SKILL.md +31 -2
  48. package/packages/notify/src/__tests__/event-bus.test.ts +3 -2
  49. package/packages/notify/src/__tests__/permission-prompt-message.test.ts +172 -0
  50. package/packages/notify/tui/gotify-setup.ts +2 -1
  51. package/packages/notify/tui/ntfy-setup.ts +2 -1
  52. package/packages/notify/tui/recap-model-selector.ts +2 -2
  53. package/packages/notify/tui/settings-overlay.ts +2 -1
  54. package/packages/notify/tui/telegram-setup.ts +2 -1
  55. package/packages/ralph/package.json +3 -3
  56. package/packages/subagents/package.json +5 -5
  57. package/packages/subagents/src/__tests__/shutdown-stale-ctx.test.ts +185 -0
  58. package/packages/subagents/src/index.ts +52 -10
  59. package/packages/unipi/index.ts +2 -0
  60. package/packages/updater/package.json +3 -3
  61. package/packages/updater/src/tui/changelog-overlay.ts +2 -2
  62. package/packages/updater/src/tui/readme-overlay.ts +2 -1
  63. package/packages/updater/src/tui/update-overlay.ts +2 -1
  64. package/packages/utility/package.json +2 -2
  65. package/packages/utility/src/tui/badge-settings-tui.ts +2 -2
  66. package/packages/utility/src/tui/util-settings-tui.ts +2 -2
  67. package/packages/web-api/README.md +28 -11
  68. package/packages/web-api/package.json +8 -2
  69. package/packages/web-api/skills/web/SKILL.md +36 -9
  70. package/packages/web-api/src/index.ts +15 -0
  71. package/packages/web-api/src/providers/duckduckgo.ts +71 -27
  72. package/packages/web-api/src/providers/firecrawl.ts +1 -1
  73. package/packages/web-api/src/providers/jina-reader.ts +1 -1
  74. package/packages/web-api/src/providers/jina-search.ts +1 -1
  75. package/packages/web-api/src/providers/perplexity.ts +2 -2
  76. package/packages/web-api/src/providers/serpapi.ts +1 -1
  77. package/packages/web-api/src/providers/tavily.ts +1 -1
  78. package/packages/web-api/src/providers/wigolo-client.ts +196 -0
  79. package/packages/web-api/src/providers/wigolo.ts +117 -0
  80. package/packages/web-api/src/settings.ts +1 -0
  81. package/packages/web-api/src/tools.ts +93 -36
  82. package/packages/workflow/package.json +2 -2
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Tests for buildPermissionPromptMessage — the `permissions:ui_prompt`
3
+ * payload projection used by the `permission_request` notify event.
4
+ *
5
+ * The test fixtures use `satisfies PermissionPromptEventPayload` to
6
+ * enforce the payload contract at compile time.
7
+ */
8
+
9
+ import { describe, it } from "node:test";
10
+ import assert from "node:assert/strict";
11
+
12
+ import {
13
+ buildPermissionPromptMessage,
14
+ type PermissionPromptEventPayload,
15
+ } from "../../permission-prompt-message.ts";
16
+
17
+ describe("buildPermissionPromptMessage", () => {
18
+ it("formats a standard bash permission payload", () => {
19
+ const payload = {
20
+ requestId: "req-1",
21
+ source: "tool",
22
+ surface: "bash",
23
+ value: "npm test",
24
+ agentName: "Current agent",
25
+ message: "Allow this command?",
26
+ } satisfies PermissionPromptEventPayload;
27
+
28
+ assert.equal(
29
+ buildPermissionPromptMessage(payload),
30
+ "Current agent requested bash 'npm test'. Allow this command?",
31
+ );
32
+ });
33
+
34
+ it("falls back to \"Agent\" when agentName is absent", () => {
35
+ const payload = {
36
+ surface: "edit",
37
+ value: "src/index.ts",
38
+ } satisfies PermissionPromptEventPayload;
39
+
40
+ assert.equal(
41
+ buildPermissionPromptMessage(payload),
42
+ "Agent requested edit 'src/index.ts'.",
43
+ );
44
+ });
45
+
46
+ it("marks forwarded subagent prompts", () => {
47
+ const payload = {
48
+ surface: "mcp",
49
+ value: "github.create_issue",
50
+ agentName: "explore",
51
+ forwarding: { from: "subagent-1" },
52
+ } satisfies PermissionPromptEventPayload;
53
+
54
+ assert.equal(
55
+ buildPermissionPromptMessage(payload),
56
+ "explore requested mcp 'github.create_issue'. (forwarded)",
57
+ );
58
+ });
59
+
60
+ it("does not mark prompts when forwarding is explicitly falsy", () => {
61
+ for (const forwarding of [false, null, "", 0, {}, []]) {
62
+ const payload = {
63
+ surface: "read",
64
+ value: "/etc/hosts",
65
+ forwarding,
66
+ } satisfies PermissionPromptEventPayload;
67
+
68
+ const result = buildPermissionPromptMessage(payload);
69
+ assert.equal(
70
+ result.includes("(forwarded)"),
71
+ false,
72
+ `expected no forwarded marker for ${JSON.stringify(forwarding)}`,
73
+ );
74
+ }
75
+ });
76
+
77
+ it("uses surface alone when no value is provided", () => {
78
+ const payload = { surface: "bash" } satisfies PermissionPromptEventPayload;
79
+
80
+ assert.equal(
81
+ buildPermissionPromptMessage(payload),
82
+ "Agent requested bash access.",
83
+ );
84
+ });
85
+
86
+ it("uses value alone when no surface is provided", () => {
87
+ const payload = { value: "rm -rf build" } satisfies PermissionPromptEventPayload;
88
+
89
+ assert.equal(
90
+ buildPermissionPromptMessage(payload),
91
+ "Agent requested 'rm -rf build'.",
92
+ );
93
+ });
94
+
95
+ it("falls back to the message when surface and value are missing", () => {
96
+ const payload = {
97
+ requestId: "req-2",
98
+ message: "Allow access to the workspace?",
99
+ } satisfies PermissionPromptEventPayload;
100
+
101
+ assert.equal(
102
+ buildPermissionPromptMessage(payload),
103
+ "Allow access to the workspace?",
104
+ );
105
+ });
106
+
107
+ it("does not duplicate the message when it equals the request clause", () => {
108
+ const payload = {
109
+ surface: "bash",
110
+ value: "npm test",
111
+ agentName: "Current agent",
112
+ message: "Current agent requested bash 'npm test'.",
113
+ } satisfies PermissionPromptEventPayload;
114
+
115
+ assert.equal(
116
+ buildPermissionPromptMessage(payload),
117
+ "Current agent requested bash 'npm test'.",
118
+ );
119
+ });
120
+
121
+ it("returns a generic fallback for an empty payload", () => {
122
+ assert.equal(
123
+ buildPermissionPromptMessage({}),
124
+ "Pi is waiting for a permission decision.",
125
+ );
126
+ });
127
+
128
+ it("handles null, undefined and non-object payloads without throwing", () => {
129
+ for (const payload of [null, undefined, 42, "nope", true]) {
130
+ assert.equal(
131
+ buildPermissionPromptMessage(payload),
132
+ "Pi is waiting for a permission decision.",
133
+ );
134
+ }
135
+ });
136
+
137
+ it("treats empty and whitespace-only strings as absent", () => {
138
+ const payload = {
139
+ surface: " ",
140
+ value: "",
141
+ agentName: " ",
142
+ message: "\t\n",
143
+ } satisfies PermissionPromptEventPayload;
144
+
145
+ assert.equal(
146
+ buildPermissionPromptMessage(payload),
147
+ "Pi is waiting for a permission decision.",
148
+ );
149
+ });
150
+
151
+ it("ignores non-string surface/value/message fields", () => {
152
+ const payload = {
153
+ surface: 123,
154
+ value: { cmd: "ls" },
155
+ message: ["a"],
156
+ } as unknown as PermissionPromptEventPayload;
157
+
158
+ assert.equal(
159
+ buildPermissionPromptMessage(payload),
160
+ "Pi is waiting for a permission decision.",
161
+ );
162
+ });
163
+
164
+ it("still reports a forwarded prompt with no other detail", () => {
165
+ const payload = { forwarding: true } satisfies PermissionPromptEventPayload;
166
+
167
+ assert.equal(
168
+ buildPermissionPromptMessage(payload),
169
+ "Pi is waiting for a permission decision. (forwarded)",
170
+ );
171
+ });
172
+ });
@@ -11,6 +11,7 @@ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
11
  import type { Theme } from "@earendil-works/pi-coding-agent";
12
12
  import { sendGotifyNotification } from "../platforms/gotify.js";
13
13
  import { updateConfig, loadConfig } from "../settings.js";
14
+ import { boxInnerWidth } from "@pi-unipi/core";
14
15
 
15
16
  type SetupPhase =
16
17
  | "instructions"
@@ -235,7 +236,7 @@ export class GotifySetupOverlay implements Component {
235
236
  }
236
237
 
237
238
  render(width: number): string[] {
238
- const innerWidth = Math.max(22, width - 2);
239
+ const innerWidth = boxInnerWidth(width);
239
240
  const lines: string[] = [];
240
241
 
241
242
  lines.push(this.borderLine(innerWidth, "top"));
@@ -11,6 +11,7 @@ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
11
  import type { Theme } from "@earendil-works/pi-coding-agent";
12
12
  import { sendNtfyNotification } from "../platforms/ntfy.js";
13
13
  import { loadNtfyConfig, saveNtfyConfig, getNtfyConfigScope } from "../ntfy-config.js";
14
+ import { boxInnerWidth } from "@pi-unipi/core";
14
15
 
15
16
  type SetupPhase =
16
17
  | "instructions"
@@ -281,7 +282,7 @@ export class NtfySetupOverlay implements Component {
281
282
  }
282
283
 
283
284
  render(width: number): string[] {
284
- const innerWidth = Math.max(22, width - 2);
285
+ const innerWidth = boxInnerWidth(width);
285
286
  const lines: string[] = [];
286
287
 
287
288
  lines.push(this.borderLine(innerWidth, "top"));
@@ -8,7 +8,7 @@
8
8
  import type { Component } from "@earendil-works/pi-tui";
9
9
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
10
  import type { Theme } from "@earendil-works/pi-coding-agent";
11
- import { readModelCache, type CachedModel } from "@pi-unipi/core";
11
+ import { readModelCache, type CachedModel, boxInnerWidth } from "@pi-unipi/core";
12
12
  import { loadConfig, saveConfig } from "../settings.js";
13
13
 
14
14
  const DEFAULT_MODEL = "openrouter/openai/gpt-oss-20b";
@@ -170,7 +170,7 @@ export class RecapModelSelectorOverlay implements Component {
170
170
  }
171
171
 
172
172
  render(width: number): string[] {
173
- const innerWidth = Math.max(40, width - 2);
173
+ const innerWidth = boxInnerWidth(width);
174
174
  const lines: string[] = [];
175
175
 
176
176
  lines.push(this.borderLine(innerWidth, "top"));
@@ -15,6 +15,7 @@ import {
15
15
  } from "../settings.js";
16
16
  import { loadNtfyConfig, saveNtfyConfig, getNtfyConfigScope } from "../ntfy-config.js";
17
17
  import type { NotifyConfig, NtfyConfig } from "../types.js";
18
+ import { boxInnerWidth } from "@pi-unipi/core";
18
19
 
19
20
  /** Section types */
20
21
  type Section = "platforms" | "events" | "recap";
@@ -174,7 +175,7 @@ export class NotifySettingsOverlay implements Component {
174
175
  }
175
176
 
176
177
  render(width: number): string[] {
177
- const innerWidth = Math.max(22, width - 2);
178
+ const innerWidth = boxInnerWidth(width);
178
179
  const lines: string[] = [];
179
180
 
180
181
  lines.push(this.borderLine(innerWidth, "top"));
@@ -10,6 +10,7 @@ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
10
  import type { Theme } from "@earendil-works/pi-coding-agent";
11
11
  import { pollForChatId } from "../platforms/telegram.js";
12
12
  import { updateConfig } from "../settings.js";
13
+ import { boxInnerWidth } from "@pi-unipi/core";
13
14
 
14
15
  type SetupPhase = "instructions" | "token" | "polling" | "success" | "error" | "timeout";
15
16
 
@@ -224,7 +225,7 @@ export class TelegramSetupOverlay implements Component {
224
225
  }
225
226
 
226
227
  render(width: number): string[] {
227
- const innerWidth = Math.max(22, width - 2);
228
+ const innerWidth = boxInnerWidth(width);
228
229
  const lines: string[] = [];
229
230
 
230
231
  lines.push(this.borderLine(innerWidth, "top"));
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/ralph",
3
- "version": "2.1.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.1.2",
31
- "@pi-unipi/info-screen": "2.1.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.1.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",
@@ -14,12 +14,12 @@
14
14
  "scripts": {
15
15
  "build": "tsc",
16
16
  "dev": "tsc --watch",
17
- "test": "node --test src/__tests__/*.test.ts"
17
+ "test": "npx tsx --test src/__tests__/*.test.ts"
18
18
  },
19
19
  "dependencies": {
20
- "@pi-unipi/core": "2.1.2",
21
- "@pi-unipi/workflow": "2.1.2",
22
- "@pi-unipi/info-screen": "2.1.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
- pi.sendMessage<NotificationDetails>(
184
- {
185
- customType: "subagent-notification",
186
- content: notificationXml,
187
- display: true,
188
- details,
189
- },
190
- { deliverAs: "followUp", triggerTurn: true },
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
  });
@@ -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.1.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",
@@ -27,13 +27,13 @@
27
27
  "README.md"
28
28
  ],
29
29
  "scripts": {
30
- "test": "node --experimental-strip-types --test tests/**/*.test.ts"
30
+ "test": "npx tsx --test tests/**/*.test.ts"
31
31
  },
32
32
  "publishConfig": {
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@pi-unipi/core": "2.1.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 = Math.max(22, width - 2);
75
+ const innerWidth = boxInnerWidth(width);
76
76
  const lines: string[] = [];
77
77
 
78
78
  // ── Header ──────────────────────────────────────────────────────
@@ -12,6 +12,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
12
12
  import { discoverReadmes, resolveReadmePath } from "../readme.js";
13
13
  import { renderMarkdown } from "../markdown.js";
14
14
  import type { ReadmeEntry } from "../../types.js";
15
+ import { boxInnerWidth } from "@pi-unipi/core";
15
16
 
16
17
  type View = "list" | "content";
17
18
 
@@ -71,7 +72,7 @@ export function renderReadmeOverlay(params?: { openDirect?: string }) {
71
72
  const render = (width: number): string[] => {
72
73
  ensureLoaded();
73
74
 
74
- const innerWidth = Math.max(22, width - 2);
75
+ const innerWidth = boxInnerWidth(width);
75
76
  const lines: string[] = [];
76
77
 
77
78
  // ── Header ──────────────────────────────────────────────────────
@@ -15,6 +15,7 @@ import { installUpdate } from "../installer.js";
15
15
  import { writeSkippedVersion } from "../cache.js";
16
16
  import { loadConfig } from "../settings.js";
17
17
  import type { ChangelogEntry, UpdateCheckResult } from "../../types.js";
18
+ import { boxInnerWidth } from "@pi-unipi/core";
18
19
 
19
20
  /**
20
21
  * Pad content to exact visible width.
@@ -119,7 +120,7 @@ export function renderUpdateOverlay(checkResult: UpdateCheckResult) {
119
120
  };
120
121
 
121
122
  const render = (width: number): string[] => {
122
- const innerWidth = Math.max(22, width - 2);
123
+ const innerWidth = boxInnerWidth(width);
123
124
  const lines: string[] = [];
124
125
 
125
126
  // ── Header ──────────────────────────────────────────────────────