@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-commands",
3
- "version": "0.2.0",
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
  }
@@ -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 publish = async (job: BtwJob, details: BtwMessageDetails, ctx: ExtensionCommandContext) => {
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
- await ctx.waitForIdle();
144
+ return;
113
145
  }
114
146
 
115
- pi.sendMessage<BtwMessageDetails>({
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
- void publish(
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
- ).catch(() => {});
219
+ );
194
220
  })
195
221
  .catch((error) => {
196
222
  job.status = "error";
197
223
  job.error = error instanceof Error ? error.message : String(error);
198
- void publish(
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
- ).catch(() => {});
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
  }
@@ -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
- }