@xynogen/pix-commands 0.2.0 → 0.2.3
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/package.json +4 -1
- package/src/btw/index.test.ts +27 -1
- package/src/btw/index.ts +60 -19
- package/src/btw/session.ts +0 -1
- package/src/extension.ts +1 -1
- package/src/once.ts +0 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-commands",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Pi extension — slash commands for cache clearing and isolated side questions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -43,5 +43,8 @@
|
|
|
43
43
|
"@earendil-works/pi-ai": "*",
|
|
44
44
|
"@earendil-works/pi-coding-agent": "*",
|
|
45
45
|
"@earendil-works/pi-tui": "*"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@xynogen/pix-runtime": "^0.2.0"
|
|
46
49
|
}
|
|
47
50
|
}
|
package/src/btw/index.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { filterBtwMessages, shortModelName, summarizeLiveText } from "./index.ts";
|
|
2
|
+
import { filterBtwMessages, registerBtw, shortModelName, summarizeLiveText } from "./index.ts";
|
|
3
3
|
|
|
4
4
|
describe("BTW display helpers", () => {
|
|
5
5
|
test("prefers model display name and falls back to id", () => {
|
|
@@ -25,4 +25,30 @@ describe("BTW display helpers", () => {
|
|
|
25
25
|
]);
|
|
26
26
|
expect(messages).toHaveLength(3);
|
|
27
27
|
});
|
|
28
|
+
|
|
29
|
+
test("does not defer an empty card flush after agent_end", async () => {
|
|
30
|
+
const handlers = new Map<string, (...args: any[]) => unknown>();
|
|
31
|
+
const pi = {
|
|
32
|
+
on(event: string, handler: (...args: any[]) => unknown) {
|
|
33
|
+
handlers.set(event, handler);
|
|
34
|
+
},
|
|
35
|
+
registerCommand() {},
|
|
36
|
+
registerMessageRenderer() {},
|
|
37
|
+
} as any;
|
|
38
|
+
registerBtw(pi);
|
|
39
|
+
|
|
40
|
+
let idleChecks = 0;
|
|
41
|
+
handlers.get("agent_end")?.(
|
|
42
|
+
{},
|
|
43
|
+
{
|
|
44
|
+
isIdle() {
|
|
45
|
+
idleChecks++;
|
|
46
|
+
throw new Error("stale extension context");
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
await Bun.sleep(10);
|
|
51
|
+
|
|
52
|
+
expect(idleChecks).toBe(0);
|
|
53
|
+
});
|
|
28
54
|
});
|
package/src/btw/index.ts
CHANGED
|
@@ -48,7 +48,11 @@ export function registerBtw(pi: ExtensionAPI): void {
|
|
|
48
48
|
const jobs = new Map<number, BtwJob>();
|
|
49
49
|
let nextId = 1;
|
|
50
50
|
let latestUi: ExtensionCommandContext["ui"] | undefined;
|
|
51
|
+
let latestContext: Pick<ExtensionCommandContext, "isIdle"> | undefined;
|
|
52
|
+
let active = true;
|
|
51
53
|
let refreshTimer: ReturnType<typeof setInterval> | undefined;
|
|
54
|
+
let publishTimer: ReturnType<typeof setTimeout> | undefined;
|
|
55
|
+
const pendingCards: BtwMessageDetails[] = [];
|
|
52
56
|
|
|
53
57
|
const renderJobs = (theme: Theme): string[] => {
|
|
54
58
|
const running = [...jobs.values()].filter((job) => job.status === "running");
|
|
@@ -66,7 +70,7 @@ export function registerBtw(pi: ExtensionAPI): void {
|
|
|
66
70
|
};
|
|
67
71
|
|
|
68
72
|
const updateUi = () => {
|
|
69
|
-
if (!latestUi) return;
|
|
73
|
+
if (!active || !latestUi) return;
|
|
70
74
|
const running = [...jobs.values()].filter((job) => job.status === "running");
|
|
71
75
|
if (running.length === 0) {
|
|
72
76
|
latestUi.setStatus(STATUS_KEY, undefined);
|
|
@@ -93,15 +97,43 @@ export function registerBtw(pi: ExtensionAPI): void {
|
|
|
93
97
|
refreshTimer ??= setInterval(updateUi, 100);
|
|
94
98
|
};
|
|
95
99
|
|
|
96
|
-
const
|
|
100
|
+
const flushPendingCards = () => {
|
|
101
|
+
// Child BTW sessions load this extension too, so their agent_end event can
|
|
102
|
+
// reach here with no cards. Do not touch a context that may be invalidated
|
|
103
|
+
// immediately afterward when the completed child session is disposed.
|
|
104
|
+
if (!active || pendingCards.length === 0 || !latestContext?.isIdle()) return;
|
|
105
|
+
for (const details of pendingCards.splice(0)) {
|
|
106
|
+
pi.sendMessage<BtwMessageDetails>({
|
|
107
|
+
customType: "pix-btw-answer",
|
|
108
|
+
content: details.error
|
|
109
|
+
? `BTW question failed: ${details.error}`
|
|
110
|
+
: `BTW answer to “${details.question}”:\n\n${details.answer}`,
|
|
111
|
+
display: true,
|
|
112
|
+
details,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const scheduleCardFlush = () => {
|
|
118
|
+
if (!active || pendingCards.length === 0 || publishTimer) return;
|
|
119
|
+
// agent_end handlers run before Pi clears its streaming flag. Flush on the
|
|
120
|
+
// next macrotask, when sendMessage appends and renders synchronously instead
|
|
121
|
+
// of entering a queue that only becomes visible on another user turn.
|
|
122
|
+
publishTimer = setTimeout(() => {
|
|
123
|
+
publishTimer = undefined;
|
|
124
|
+
flushPendingCards();
|
|
125
|
+
}, 0);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const publish = (job: BtwJob, details: BtwMessageDetails, ctx: ExtensionCommandContext) => {
|
|
97
129
|
job.session?.dispose();
|
|
98
130
|
job.session = undefined;
|
|
131
|
+
// The side session may finish while the main extension runtime is being
|
|
132
|
+
// replaced. Never touch its captured pi/ctx/UI after shutdown begins.
|
|
133
|
+
if (!active) return;
|
|
99
134
|
updateUi();
|
|
135
|
+
pendingCards.push(details);
|
|
100
136
|
|
|
101
|
-
// Sending a custom message while the main agent streams would enqueue it
|
|
102
|
-
// as a steer and leak the side answer into the in-flight task. Surface the
|
|
103
|
-
// result immediately as a UI notification, then wait to append the durable
|
|
104
|
-
// rendered transcript card until the main agent is idle.
|
|
105
137
|
if (!ctx.isIdle()) {
|
|
106
138
|
ctx.ui.notify(
|
|
107
139
|
details.error
|
|
@@ -109,17 +141,10 @@ export function registerBtw(pi: ExtensionAPI): void {
|
|
|
109
141
|
: `BTW #${job.id} complete\n\n${details.answer}`,
|
|
110
142
|
details.error ? "error" : "info",
|
|
111
143
|
);
|
|
112
|
-
|
|
144
|
+
return;
|
|
113
145
|
}
|
|
114
146
|
|
|
115
|
-
|
|
116
|
-
customType: "pix-btw-answer",
|
|
117
|
-
content: details.error
|
|
118
|
-
? `BTW question failed: ${details.error}`
|
|
119
|
-
: `BTW answer to “${details.question}”:\n\n${details.answer}`,
|
|
120
|
-
display: true,
|
|
121
|
-
details,
|
|
122
|
-
});
|
|
147
|
+
flushPendingCards();
|
|
123
148
|
};
|
|
124
149
|
|
|
125
150
|
pi.on("context", (event) => ({ messages: filterBtwMessages(event.messages) }));
|
|
@@ -129,6 +154,7 @@ export function registerBtw(pi: ExtensionAPI): void {
|
|
|
129
154
|
handler: async (rawArgs, ctx) => {
|
|
130
155
|
const question = rawArgs.trim();
|
|
131
156
|
latestUi = ctx.ui;
|
|
157
|
+
latestContext = ctx;
|
|
132
158
|
if (!question) {
|
|
133
159
|
ctx.ui.notify("Usage: /btw <question>", "warning");
|
|
134
160
|
return;
|
|
@@ -179,7 +205,7 @@ export function registerBtw(pi: ExtensionAPI): void {
|
|
|
179
205
|
job.status = "completed";
|
|
180
206
|
job.text = text;
|
|
181
207
|
job.session = session;
|
|
182
|
-
|
|
208
|
+
publish(
|
|
183
209
|
job,
|
|
184
210
|
{
|
|
185
211
|
question,
|
|
@@ -190,12 +216,12 @@ export function registerBtw(pi: ExtensionAPI): void {
|
|
|
190
216
|
toolUses: job.toolUses,
|
|
191
217
|
},
|
|
192
218
|
ctx,
|
|
193
|
-
)
|
|
219
|
+
);
|
|
194
220
|
})
|
|
195
221
|
.catch((error) => {
|
|
196
222
|
job.status = "error";
|
|
197
223
|
job.error = error instanceof Error ? error.message : String(error);
|
|
198
|
-
|
|
224
|
+
publish(
|
|
199
225
|
job,
|
|
200
226
|
{
|
|
201
227
|
question,
|
|
@@ -207,19 +233,33 @@ export function registerBtw(pi: ExtensionAPI): void {
|
|
|
207
233
|
error: job.error,
|
|
208
234
|
},
|
|
209
235
|
ctx,
|
|
210
|
-
)
|
|
236
|
+
);
|
|
211
237
|
});
|
|
212
238
|
},
|
|
213
239
|
});
|
|
214
240
|
|
|
241
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
242
|
+
if (!active) return;
|
|
243
|
+
latestContext = ctx;
|
|
244
|
+
scheduleCardFlush();
|
|
245
|
+
});
|
|
246
|
+
|
|
215
247
|
pi.on("session_start", (_event, ctx) => {
|
|
248
|
+
if (!active) return;
|
|
216
249
|
latestUi = ctx.ui;
|
|
250
|
+
latestContext = ctx;
|
|
217
251
|
updateUi();
|
|
218
252
|
});
|
|
219
253
|
|
|
220
254
|
pi.on("session_shutdown", () => {
|
|
255
|
+
// Set this before clearing timers: already-queued callbacks and late BTW
|
|
256
|
+
// completions must become no-ops before Pi invalidates this runtime.
|
|
257
|
+
active = false;
|
|
221
258
|
if (refreshTimer) clearInterval(refreshTimer);
|
|
259
|
+
if (publishTimer) clearTimeout(publishTimer);
|
|
222
260
|
refreshTimer = undefined;
|
|
261
|
+
publishTimer = undefined;
|
|
262
|
+
latestContext = undefined;
|
|
223
263
|
latestUi?.setStatus(STATUS_KEY, undefined);
|
|
224
264
|
latestUi?.setWidget(WIDGET_KEY, undefined);
|
|
225
265
|
for (const job of jobs.values()) {
|
|
@@ -230,5 +270,6 @@ export function registerBtw(pi: ExtensionAPI): void {
|
|
|
230
270
|
job.session?.dispose();
|
|
231
271
|
}
|
|
232
272
|
jobs.clear();
|
|
273
|
+
pendingCards.length = 0;
|
|
233
274
|
});
|
|
234
275
|
}
|
package/src/btw/session.ts
CHANGED
|
@@ -127,7 +127,6 @@ export async function runBtw(options: BtwRunOptions): Promise<BtwRunResult> {
|
|
|
127
127
|
agentDir,
|
|
128
128
|
sessionManager: SessionManager.inMemory(snapshot.cwd),
|
|
129
129
|
settingsManager,
|
|
130
|
-
modelRegistry: ctx.modelRegistry,
|
|
131
130
|
model: snapshot.model,
|
|
132
131
|
thinkingLevel: snapshot.thinkingLevel,
|
|
133
132
|
tools,
|
package/src/extension.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { once } from "@xynogen/pix-runtime/once";
|
|
2
3
|
import { registerBtw } from "./btw/index.ts";
|
|
3
4
|
import registerClear from "./clear.ts";
|
|
4
|
-
import { once } from "./once.ts";
|
|
5
5
|
|
|
6
6
|
export default function (pi: ExtensionAPI): void {
|
|
7
7
|
once(pi, "pix-commands", () => {
|
package/src/once.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Per-instance idempotency guard for extension activation.
|
|
3
|
-
*
|
|
4
|
-
* pix-core (the meta-package) invokes this package's factory, and a standalone
|
|
5
|
-
* install makes Pi invoke it again — sometimes against the SAME `pi`. We must
|
|
6
|
-
* dedupe that. But Pi rebuilds the extension runtime on /new, /resume, /fork,
|
|
7
|
-
* and /reload, handing the factory a BRAND-NEW `pi`; that must re-register.
|
|
8
|
-
*
|
|
9
|
-
* Keying the registry on the `pi` instance satisfies both: same instance =>
|
|
10
|
-
* skip, new instance => run. The registry lives on globalThis because jiti
|
|
11
|
-
* (`moduleCache: false`) re-evaluates this module on every load pass, so a
|
|
12
|
-
* module-scoped WeakMap would not be shared between the aggregator pass and the
|
|
13
|
-
* standalone pass within a single session.
|
|
14
|
-
*/
|
|
15
|
-
export function once(pi: object, key: string, fn: () => void): void {
|
|
16
|
-
const g = globalThis as { __pixOnce?: WeakMap<object, Set<string>> };
|
|
17
|
-
if (!g.__pixOnce) g.__pixOnce = new WeakMap<object, Set<string>>();
|
|
18
|
-
const registry = g.__pixOnce;
|
|
19
|
-
let loaded = registry.get(pi);
|
|
20
|
-
if (!loaded) {
|
|
21
|
-
loaded = new Set<string>();
|
|
22
|
-
registry.set(pi, loaded);
|
|
23
|
-
}
|
|
24
|
-
if (loaded.has(key)) return;
|
|
25
|
-
loaded.add(key);
|
|
26
|
-
fn();
|
|
27
|
-
}
|