pum-agent 0.2.36-beta.1 → 0.2.37-beta.1

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": "pum-agent",
3
- "version": "0.2.36-beta.1",
3
+ "version": "0.2.37-beta.1",
4
4
  "description": "A compact terminal coding agent powered by pi and OpenTUI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -50,8 +50,8 @@
50
50
  "pack:check": "bun run scripts/validate-pack.ts"
51
51
  },
52
52
  "dependencies": {
53
- "@earendil-works/pi-ai": "^0.87.1",
54
- "@earendil-works/pi-coding-agent": "^0.87.1",
53
+ "@earendil-works/pi-ai": "1.1.0",
54
+ "@earendil-works/pi-coding-agent": "1.1.0",
55
55
  "@opentui/core": "^0.5.1",
56
56
  "@opentui/react": "^0.5.1",
57
57
  "react": "^19.2.8",
package/src/app.tsx CHANGED
@@ -62,7 +62,6 @@ import {
62
62
  rawToolText,
63
63
  resolveGoalReview,
64
64
  resolvePendingDelivery,
65
- settleTranscriptMessage,
66
65
  streamedDelta,
67
66
  StreamLine,
68
67
  TextLine,
@@ -74,6 +73,7 @@ import {
74
73
  } from "./transcript";
75
74
  import { bashOutput, bashResultDisplay, editCounts, toolArgs, type ToolCall } from "./tool-line";
76
75
  import { interruptedToolCall, settledToolCall, startedToolCall } from "./tool-row";
76
+ import { settleAssistantTranscript } from "./provider-error";
77
77
  import { toolPreviewFromResult, toolPreviewFromStart } from "./tool-preview";
78
78
  import { readBranch, watchBranch } from "./git-branch";
79
79
  import { HelpPopup, maxHelpScrollOffset } from "./help-popup";
@@ -2222,7 +2222,7 @@ export function App({
2222
2222
  }
2223
2223
  case "message_end":
2224
2224
  if (event.message.role === "assistant") {
2225
- setTx((value) => settleTranscriptMessage(value));
2225
+ setTx((value) => settleAssistantTranscript(value, event.message));
2226
2226
  } else if (event.message.role === "custom" && event.message.customType === VALIDATION_CUSTOM_TYPE) {
2227
2227
  append({
2228
2228
  kind: "text",
@@ -580,7 +580,12 @@ export type BashToolLike = {
580
580
  signal?: AbortSignal,
581
581
  onUpdate?: (update: { content: unknown[]; details?: unknown }) => void,
582
582
  ctx?: unknown,
583
- ) => Promise<{ content: Array<{ type: string; text: string }>; details?: { fullOutputPath?: string } }>;
583
+ ) => Promise<{
584
+ content: Array<{ type: string; text: string }>;
585
+ details?: { fullOutputPath?: string };
586
+ isError?: boolean;
587
+ structuredContent?: unknown;
588
+ }>;
584
589
  };
585
590
 
586
591
  export const bashOutputParameters = Type.Object({
@@ -666,16 +671,27 @@ export function withBashOutput(
666
671
  await capture.remove();
667
672
  return result; // Fail open to pi's bounded native result; never lose output.
668
673
  }
669
- const source = captured ?? textOf(result.content);
670
- const summary = summarizeBashOutput(source, perCall, {
671
- exitCode: 0,
674
+ const nativeText = textOf(result.content);
675
+ // pi 1.1.0 returns nonzero exits as error results, not thrown Errors.
676
+ // Keep native status/error identity while summarizing our trusted stream.
677
+ const exit = result.isError ? EXIT_RE.exec(nativeText) : null;
678
+ const status = exit ? `Command exited with code ${exit[2]}` : undefined;
679
+ if (result.isError && !exit) {
680
+ await capture?.remove();
681
+ return result; // Unknown error shape: preserve native evidence intact.
682
+ }
683
+ const source = captured ?? (exit ? exit[1] : nativeText);
684
+ const summary = summarizeBashOutput(source, status
685
+ ? { ...perCall, maxBytes: Math.max(64, perCall.maxBytes - byteLen(status) - 2) }
686
+ : perCall, {
687
+ exitCode: exit ? Number(exit[2]) : 0,
672
688
  path: capture?.path,
673
689
  patterns,
674
690
  });
675
691
  if (capture && !summary.lossy) await capture.remove();
676
692
  return {
677
693
  ...result,
678
- content: [{ type: "text", text: summary.content }],
694
+ content: [{ type: "text", text: status ? `${summary.content}\n\n${status}` : summary.content }],
679
695
  details: capture && summary.lossy ? { fullOutputPath: capture.path } : result.details,
680
696
  };
681
697
  } catch (error) {
@@ -1,4 +1,4 @@
1
- import { createReadToolDefinition, type ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import { createReadToolDefinition, type ExtensionToolContext } from "@earendil-works/pi-coding-agent";
2
2
  import { createHash } from "node:crypto";
3
3
  import { constants, type Stats } from "node:fs";
4
4
  import { lstat, open } from "node:fs/promises";
@@ -138,11 +138,11 @@ export class FileMutationGuard {
138
138
  }
139
139
 
140
140
  /** Preserve native image handling and filename retries; observe only a stable successful read. */
141
- async read(id: string, input: any, signal?: AbortSignal, onUpdate?: any, ctx?: ExtensionContext, autoResizeImages?: boolean): Promise<any> {
141
+ async read(id: string, input: any, signal?: AbortSignal, onUpdate?: any, ctx?: ExtensionToolContext, autoResizeImages?: boolean): Promise<any> {
142
142
  const target = await this.target(input.path, "read");
143
143
  const before = await this.snapshot(target.path, "read");
144
144
  const result = await createReadToolDefinition(this.cwd, { autoResizeImages }).execute(
145
- id, input, signal, onUpdate, ctx ?? { cwd: this.cwd } as ExtensionContext,
145
+ id, input, signal, onUpdate, ctx ?? { cwd: this.cwd } as ExtensionToolContext,
146
146
  );
147
147
  const after = await this.snapshot(target.path, "read");
148
148
  if (!signal?.aborted && !this.disposed && before.fingerprint === after.fingerprint) this.remember(target.key, before);
@@ -290,8 +290,9 @@ export class ProjectValidationController {
290
290
  outcome = deadline.signal.aborted ? "timeout" : "cancelled";
291
291
  } else {
292
292
  const result = await tool.execute(toolCall.id, args, signal);
293
- const after = await untilAborted(Promise.resolve(session.agent.afterToolCall?.({ assistantMessage, toolCall, args, result, isError: false, context }, signal)), signal);
294
- if (after?.isError) outcome = "failed";
293
+ const isError = result.isError === true;
294
+ const after = await untilAborted(Promise.resolve(session.agent.afterToolCall?.({ assistantMessage, toolCall, args, result, isError, context }, signal)), signal);
295
+ if (after?.isError ?? isError) outcome = "failed";
295
296
  output = (after?.content ?? result.content).filter((item) => item.type === "text").map((item) => item.text).join("\n");
296
297
  }
297
298
  } catch (error) {
@@ -0,0 +1,66 @@
1
+ import { flushStream, settleTranscriptMessage, type Line, type PendingTranscriptState } from "./transcript";
2
+
3
+ /** Redact entire header values, not just the scheme or first credential token. */
4
+ function redactAuthorization(text: string): string {
5
+ const header = /(["']?\b(?:proxy-)?authorization["']?\s*[:=]\s*)/gi;
6
+ let safe = "";
7
+ let copied = 0;
8
+ for (let match = header.exec(text); match; match = header.exec(text)) {
9
+ const start = header.lastIndex;
10
+ let end = start;
11
+ let quote = "";
12
+ for (; end < text.length; end++) {
13
+ const char = text[end]!;
14
+ if (quote) {
15
+ // JSON-escaped Digest quotes are part of the value, not its boundary.
16
+ if (char === "\\") { end++; continue; }
17
+ if (char === quote) quote = "";
18
+ continue;
19
+ }
20
+ if (char === '"' || char === "'") { quote = char; continue; }
21
+ // Digest commas and arbitrary scheme whitespace/semicolons can contain
22
+ // credentials. Only a structured next field or explicit schema diagnostic
23
+ // is a safe boundary; ambiguous/unclosed values consume the remaining text.
24
+ if (char === "," && /^,\s*["'][^"'\n]+["']\s*:/.test(text.slice(end))) break;
25
+ if (char === ";" && /^;\s*(?:[45]\d\d\s+)?tools\.\d+\.custom\b/.test(text.slice(end))) break;
26
+ }
27
+ safe += text.slice(copied, start) + "[redacted]";
28
+ copied = end;
29
+ header.lastIndex = end;
30
+ }
31
+ return safe + text.slice(copied);
32
+ }
33
+
34
+ /** Provider text is untrusted display data, never a diagnostic or durable rewrite. */
35
+ export function providerErrorLine(message: unknown): Line | null {
36
+ const assistant = message as { role?: unknown; stopReason?: unknown; errorMessage?: unknown } | null;
37
+ // An aborted response can also carry errorMessage; cancellation is not failure.
38
+ if (assistant?.role !== "assistant" || assistant.stopReason !== "error") return null;
39
+ const raw = typeof assistant.errorMessage === "string" ? assistant.errorMessage : "";
40
+ // As in login-flow.safeError, redact credential-shaped keys. Provider failures
41
+ // can also echo headers/JSON or an OAuth URL, so remove those before bounding.
42
+ const normalized = raw
43
+ // Normalize control sequences first so they cannot split a credential marker.
44
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
45
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
46
+ .replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, "");
47
+ const safe = redactAuthorization(normalized)
48
+ .replace(/https?:\/\/[^\s<>"']+/gi, "[redacted URL]")
49
+ .replace(/\bBearer\s+[^\s,"'}]+/gi, "Bearer [redacted]")
50
+ // Quoted JSON/header values may contain escaped quotes or span whitespace.
51
+ // An unterminated quoted secret is redacted through the end, never partially.
52
+ .replace(/(["']?\b(?:api[_ -]?key|x-api-key|access[_ -]?token|refresh[_ -]?token|password|secret|client[_ -]?secret)["']?\s*[:=]\s*)(?:"(?:\\[\s\S]|[^"\\])*(?:"|\\?$)|'(?:\\[\s\S]|[^'\\])*(?:'|\\?$)|[^\s,}\n]+)/gi, "$1[redacted]")
53
+ .replace(/\b(?:sk|key|token)-[A-Za-z0-9_.-]{8,}\b/gi, "[redacted]")
54
+ .replace(/\b(?:gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+|AKIA[A-Z0-9]{16}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\b/g, "[redacted]")
55
+ .trim();
56
+ const detail = safe ? safe.slice(0, 4000) + (safe.length > 4000 ? "…" : "") : "The request failed without an error description.";
57
+ return { kind: "text", role: "error", text: `Provider error: ${detail}` };
58
+ }
59
+
60
+ /** Finish partial output before its failure, then deliver queued messages. */
61
+ export function settleAssistantTranscript<T extends PendingTranscriptState>(value: T, message: unknown): T {
62
+ const error = providerErrorLine(message);
63
+ if (!error) return settleTranscriptMessage(value);
64
+ const flushed = flushStream(value);
65
+ return settleTranscriptMessage({ ...flushed, lines: [...flushed.lines, error] });
66
+ }
package/src/replay.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { AgentMessageKind, Line } from "./transcript";
2
+ import { providerErrorLine } from "./provider-error";
2
3
  import { toolArgs, type ToolCall } from "./tool-line";
3
4
  import { interruptedToolCall, settledToolCall, startedToolCall } from "./tool-row";
4
5
  import {
@@ -303,8 +304,8 @@ export function replayEntries(
303
304
  continue;
304
305
  }
305
306
 
306
- if (message?.role === "assistant" && Array.isArray(message.content)) {
307
- for (const block of message.content) {
307
+ if (message?.role === "assistant") {
308
+ for (const block of Array.isArray(message.content) ? message.content : []) {
308
309
  if (block?.type === "text" && block.text?.trim()) {
309
310
  lines.push({ kind: "text", role: "assistant", text: block.text.trim() });
310
311
  } else if (showThinking && block?.type === "thinking" && block.thinking?.trim()) {
@@ -322,6 +323,8 @@ export function replayEntries(
322
323
  lines.push({ kind: "tool", call });
323
324
  }
324
325
  }
326
+ const error = providerErrorLine(message);
327
+ if (error) lines.push(error);
325
328
  continue;
326
329
  }
327
330
 
@@ -20,6 +20,7 @@ import {
20
20
  usageFromEntries,
21
21
  } from "../agent-usage";
22
22
  import { replayEntries } from "../replay";
23
+ import { settleAssistantTranscript } from "../provider-error";
23
24
  import { registerOperationalNotice } from "../operational-context";
24
25
  import { bindFileCheckpointSession } from "../file-checkpoints";
25
26
  import { ProjectValidationController, VALIDATION_CUSTOM_TYPE } from "../project-validation";
@@ -76,7 +77,6 @@ import { GOAL_VERDICT_TOOL_NAME, goalVerdictParameters } from "../goal-judge";
76
77
  import {
77
78
  flushStream,
78
79
  resolvePendingDelivery,
79
- settleTranscriptMessage,
80
80
  streamedDelta,
81
81
  type Line,
82
82
  type PendingLine,
@@ -1041,7 +1041,7 @@ export class SubagentManager {
1041
1041
  this.appendLine(record, { kind: "text", role: event.message.details?.outcome === "passed" ? "system" : "error", text: String(event.message.content) });
1042
1042
  }
1043
1043
  if (event.message?.role === "assistant") {
1044
- this.updateTranscript(record, (value) => settleTranscriptMessage(value));
1044
+ this.updateTranscript(record, (value) => settleAssistantTranscript(value, event.message));
1045
1045
  }
1046
1046
  break;
1047
1047
  case "message_update": {