@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.
- package/README.md +6 -2
- package/package.json +24 -23
- package/packages/ask-user/ask-ui.ts +45 -31
- package/packages/ask-user/launcher-ui.ts +26 -22
- package/packages/ask-user/package.json +3 -3
- 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 +4 -4
- 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 +3 -3
- 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 +3 -2
- package/packages/input-shortcuts/package.json +3 -3
- package/packages/kanboard/package.json +2 -2
- 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/milestone/package.json +2 -2
- package/packages/notify/README.md +4 -1
- package/packages/notify/events.ts +130 -81
- package/packages/notify/package.json +3 -3
- package/packages/notify/permission-prompt-message.ts +95 -0
- package/packages/notify/settings.ts +2 -0
- package/packages/notify/skills/configure-notify/SKILL.md +31 -2
- package/packages/notify/src/__tests__/event-bus.test.ts +3 -2
- package/packages/notify/src/__tests__/permission-prompt-message.test.ts +172 -0
- package/packages/notify/tui/gotify-setup.ts +2 -1
- package/packages/notify/tui/ntfy-setup.ts +2 -1
- package/packages/notify/tui/recap-model-selector.ts +2 -2
- package/packages/notify/tui/settings-overlay.ts +2 -1
- package/packages/notify/tui/telegram-setup.ts +2 -1
- package/packages/ralph/package.json +3 -3
- package/packages/subagents/package.json +5 -5
- 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 +3 -3
- 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,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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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.
|
|
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",
|
|
@@ -14,12 +14,12 @@
|
|
|
14
14
|
"scripts": {
|
|
15
15
|
"build": "tsc",
|
|
16
16
|
"dev": "tsc --watch",
|
|
17
|
-
"test": "
|
|
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",
|
|
@@ -27,13 +27,13 @@
|
|
|
27
27
|
"README.md"
|
|
28
28
|
],
|
|
29
29
|
"scripts": {
|
|
30
|
-
"test": "
|
|
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.
|
|
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 ──────────────────────────────────────────────────────
|
|
@@ -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 =
|
|
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 =
|
|
123
|
+
const innerWidth = boxInnerWidth(width);
|
|
123
124
|
const lines: string[] = [];
|
|
124
125
|
|
|
125
126
|
// ── Header ──────────────────────────────────────────────────────
|