pi-ui-extend 0.1.56 → 0.1.57

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.
@@ -3,10 +3,9 @@
3
3
  * rejects HTTP 400 `Unknown parameter: 'input[N].content'` when non-message
4
4
  * items (reasoning, function_call_output) carry a spurious `content` field.
5
5
  *
6
- * Root cause is in pi-ai's `convertResponsesMessages`, which pushes replayed
7
- * items verbatim including a stray `content` placeholder. The upstream fix is
8
- * uncommitted in pi-mono and never shipped, so every released pi-ai version
9
- * (incl. 0.79.10) is affected.
6
+ * Stray fields can come from replayed pi-ai items or from another extension
7
+ * that modifies the provider payload. The sanitizer therefore must be the
8
+ * LAST `before_provider_request` handler registered by pi-tools-suite.
10
9
  *
11
10
  * The fix must run at the transport layer because the provider has TWO paths:
12
11
  *
@@ -16,16 +15,13 @@
16
15
  * strip `content` from every non-message input item of each
17
16
  * `response.create` frame, on the exact bytes leaving the socket.
18
17
  *
19
- * 2. SSE/fetch fallback path: once a websocket attempt fails (common at large
20
- * context full body, ~100+ input items), the transport records an SSE
21
- * fallback for the session and every subsequent request goes through a plain
22
- * `fetch()` POST with the FULL body (`bodyJson`). The `before_provider_request`
23
- * hook should clean this, but in practice it does not reliably catch every
24
- * offending item, so we also wrap `globalThis.fetch` once and strip the same
25
- * spurious `content` from the request body on the exact bytes leaving the
26
- * HTTP client.
18
+ * 2. SSE/fetch fallback path: once a websocket attempt fails, subsequent
19
+ * requests use the FULL body. pi-ai >= 0.80.6 zstd-compresses that body
20
+ * before fetch, so a fetch wrapper cannot inspect it. The final
21
+ * `before_provider_request` sanitizer is the primary guard for this path;
22
+ * the fetch wrapper remains a best-effort fallback for uncompressed bodies.
27
23
  *
28
- * The `before_provider_request` hook is kept as a third secondary guard.
24
+ * Module registration order is part of this workaround's correctness.
29
25
  *
30
26
  * Remove this whole module once an upstream pi-ai release carries the fix.
31
27
  */
@@ -182,6 +178,9 @@ export function stripContentFromWireFrame(frame: unknown): { frame: Record<strin
182
178
  }
183
179
 
184
180
  export default function codexReasoningFix(pi: ExtensionAPI): void {
181
+ // src/index.ts deliberately registers this module last. A later payload
182
+ // modifier could otherwise reintroduce invalid content after sanitization,
183
+ // and compressed SSE bodies are too late to repair in the fetch wrapper.
185
184
  pi.on("before_provider_request", async (event: ProviderRequestEvent, _ctx: ProviderRequestContext) => {
186
185
  const result = stripReasoningContentFromPayload(event.payload);
187
186
  return result === event.payload ? undefined : result;
@@ -132,6 +132,17 @@ function appendDcpControlToMessages(messages: unknown, text: string): unknown {
132
132
  for (let index = messages.length - 1; index >= 0; index--) {
133
133
  const message = messages[index] as any
134
134
  if (!message || typeof message !== "object") continue
135
+ // Responses items such as reasoning/function_call_output do not accept a
136
+ // `content` field. Keep a function output at the tail by appending to its
137
+ // valid `output` string; otherwise scan back to an actual message item.
138
+ if (typeof message.type === "string" && message.type !== "message" && message.role === undefined) {
139
+ if (message.type === "function_call_output" && typeof message.output === "string") {
140
+ return messages.map((candidate: any, candidateIndex) => candidateIndex === index
141
+ ? { ...candidate, output: `${candidate.output}\n\n${block}` }
142
+ : candidate)
143
+ }
144
+ continue
145
+ }
135
146
  if (message.role === "system" || message.role === "developer") continue
136
147
  targetIndex = index
137
148
  break
@@ -9,8 +9,7 @@ type ExtensionModule = {
9
9
  default: ExtensionFactory;
10
10
  };
11
11
 
12
- const MODULES: Array<{ name: string; load: () => Promise<ExtensionModule> }> = [
13
- { name: "codex-reasoning-fix", load: () => import("./codex-reasoning-fix/index") },
12
+ export const MODULES: Array<{ name: string; load: () => Promise<ExtensionModule> }> = [
14
13
  { name: "coding-discipline", load: () => import("./coding-discipline/index") },
15
14
  { name: "ast-grep", load: () => import("./ast-grep/index") },
16
15
  { name: "async-subagents", load: () => import("./async-subagents/index") },
@@ -28,6 +27,9 @@ const MODULES: Array<{ name: string; load: () => Promise<ExtensionModule> }> = [
28
27
  { name: "prompt-commands", load: () => import("./prompt-commands/index") },
29
28
  { name: "skill-installer", load: () => import("./skill-installer/index") },
30
29
  { name: "telegram-mirror", load: () => import("./telegram-mirror/index") },
30
+ // Keep this last: its before_provider_request handler is the final payload
31
+ // sanitizer after DCP and any other provider-payload modifiers.
32
+ { name: "codex-reasoning-fix", load: () => import("./codex-reasoning-fix/index") },
31
33
  ];
32
34
 
33
35
  export default async function piToolsSuite(pi: ExtensionAPI) {
@@ -10,16 +10,6 @@ import { getState, replaceState } from "./state/store.js";
10
10
  import { activateTodoStateScope, DEFAULT_PROMPT_GUIDELINES, DEFAULT_PROMPT_SNIPPET, publishTodoState, registerTodosCommand, registerTodoTool } from "./todo.js";
11
11
  import type { Task, TaskMutationParams } from "./tool/types.js";
12
12
 
13
- /**
14
- * Renderer-relayed signal that the session is in an auto-retry cycle.
15
- * Payload: `{ active: boolean }`. The SDK does not forward retry state to
16
- * extensions, so the renderer emits this on the extension event bus. We use it
17
- * to suppress the auto-nudge on intermediate retry agent_end events: nudging
18
- * then races the pending retry continuation and surfaces a benign
19
- * "Agent is already processing" extension error in other tabs.
20
- */
21
- const RETRY_ACTIVE_EVENT = "pix:retry-active";
22
-
23
13
  type AgentMessageLike = { role?: unknown; stopReason?: unknown; content?: unknown };
24
14
 
25
15
  const TODO_NUDGE_LIMIT = 8;
@@ -144,10 +134,10 @@ export default function (pi: ExtensionAPI) {
144
134
  const pendingAskUserToolCallIds = new Set<string>();
145
135
  let suppressNextNudgeForThinkingSwitch = false;
146
136
  let inProgressAtAgentStart = new Set<number>();
147
- // True while the session is in an auto-retry cycle (relayed via the
148
- // extension event bus). Suppresses the auto-nudge on intermediate retry
149
- // agent_end events so it doesn't race the pending retry continuation.
150
- let retryActive = false;
137
+ // agent_end may be followed by an automatic retry, compaction, or queued
138
+ // continuation. Remember only whether its terminal assistant reply was a
139
+ // successful visible response; schedule a nudge later on agent_settled.
140
+ let settledNudgeEligible = false;
151
141
 
152
142
  function registerTodoToolWithCurrentPrompt(): void {
153
143
  const thinkingPrompt = todoThinkingEnabled ? buildThinkingPromptParts(currentModel) : {};
@@ -336,9 +326,6 @@ export default function (pi: ExtensionAPI) {
336
326
  const delayMs = attempt === 0 ? TODO_NUDGE_INITIAL_DELAY_MS : TODO_NUDGE_IDLE_RETRY_DELAY_MS;
337
327
  nudgeTimer = setTimeout(() => {
338
328
  nudgeTimer = undefined;
339
- // A retry-start signal may have arrived after the agent_end that
340
- // scheduled this nudge. Bail out so we don't nudge mid-retry.
341
- if (retryActive) return;
342
329
  try {
343
330
  activateTodoStateScope(ctx);
344
331
  if (!ctx.isIdle()) {
@@ -358,9 +345,8 @@ export default function (pi: ExtensionAPI) {
358
345
  if (nudge.signature === lastNudgedSignature) return;
359
346
  lastNudgedSignature = nudge.signature;
360
347
 
361
- // agent_end fires before Pi is fully back in idle dispatch. Sending as a
362
- // normal user message on the next idle tick reliably starts a fresh turn;
363
- // queueing followUp from inside agent_end can be too late to be drained.
348
+ // agent_settled means retries, compaction, and queued continuations are
349
+ // finished. Send on the next idle tick as a fresh turn.
364
350
  pi.sendUserMessage(nudge.message);
365
351
  } catch (err) {
366
352
  if (isAgentBusyRaceError(err)) {
@@ -413,12 +399,6 @@ export default function (pi: ExtensionAPI) {
413
399
  clearNudgeTimer();
414
400
  });
415
401
 
416
- pi.events.on(RETRY_ACTIVE_EVENT, (data: unknown) => {
417
- const active = data != null && typeof data === "object" && (data as { active?: unknown }).active === true;
418
- retryActive = active;
419
- if (active) clearNudgeTimer();
420
- });
421
-
422
402
  pi.on("model_select", async (event) => {
423
403
  currentModel = event.model;
424
404
  if (todoThinkingEnabled) registerTodoToolWithCurrentPrompt();
@@ -439,7 +419,7 @@ export default function (pi: ExtensionAPI) {
439
419
  pi.on("agent_start", async (_event, ctx) => {
440
420
  activateTodoStateScope(ctx);
441
421
  pendingAskUserToolCallIds.clear();
442
- retryActive = false;
422
+ settledNudgeEligible = false;
443
423
  inProgressAtAgentStart = new Set(selectVisibleTasks(getState()).filter((task) => task.status === "in_progress").map((task) => task.id));
444
424
  });
445
425
 
@@ -455,26 +435,42 @@ export default function (pi: ExtensionAPI) {
455
435
  pi.on("agent_end", async (event, ctx) => {
456
436
  activateTodoStateScope(ctx);
457
437
  const completedAssistantReply = hasCompletedAssistantReply((event as { messages?: readonly unknown[] } | undefined)?.messages);
438
+ settledNudgeEligible = completedAssistantReply;
458
439
 
459
440
  if (suppressNextNudgeForThinkingSwitch) {
460
441
  suppressNextNudgeForThinkingSwitch = false;
461
442
  if (!completedAssistantReply) {
443
+ settledNudgeEligible = false;
462
444
  clearNudgeTimer();
463
445
  return;
464
446
  }
465
447
  }
466
448
 
467
449
  if (pendingAskUserToolCallIds.size > 0) {
450
+ settledNudgeEligible = false;
468
451
  clearNudgeTimer();
469
452
  return;
470
453
  }
471
454
 
472
455
  if (completedAssistantReply && maybeRecoverCompletedCurrentTask((event as { messages?: readonly unknown[] } | undefined)?.messages, ctx)) {
456
+ settledNudgeEligible = false;
473
457
  lastNudgedSignature = undefined;
474
458
  clearNudgeTimer();
475
459
  return;
476
460
  }
461
+ });
477
462
 
463
+ pi.on("agent_settled", async (_event, ctx) => {
464
+ activateTodoStateScope(ctx);
465
+ if (!settledNudgeEligible) {
466
+ clearNudgeTimer();
467
+ return;
468
+ }
469
+ settledNudgeEligible = false;
470
+ if (pendingAskUserToolCallIds.size > 0) {
471
+ clearNudgeTimer();
472
+ return;
473
+ }
478
474
  const nudge = getUnfinishedTodoNudge();
479
475
  if (!nudge) {
480
476
  lastNudgedSignature = undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "0.1.56",
3
+ "version": "0.1.57",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {