@oh-my-pi/pi-coding-agent 13.5.6 → 13.5.7

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,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "13.5.6",
4
+ "version": "13.5.7",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -41,12 +41,12 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@mozilla/readability": "^0.6",
44
- "@oh-my-pi/omp-stats": "13.5.6",
45
- "@oh-my-pi/pi-agent-core": "13.5.6",
46
- "@oh-my-pi/pi-ai": "13.5.6",
47
- "@oh-my-pi/pi-natives": "13.5.6",
48
- "@oh-my-pi/pi-tui": "13.5.6",
49
- "@oh-my-pi/pi-utils": "13.5.6",
44
+ "@oh-my-pi/omp-stats": "13.5.7",
45
+ "@oh-my-pi/pi-agent-core": "13.5.7",
46
+ "@oh-my-pi/pi-ai": "13.5.7",
47
+ "@oh-my-pi/pi-natives": "13.5.7",
48
+ "@oh-my-pi/pi-tui": "13.5.7",
49
+ "@oh-my-pi/pi-utils": "13.5.7",
50
50
  "@sinclair/typebox": "^0.34",
51
51
  "@xterm/headless": "^6.0",
52
52
  "ajv": "^8.18",
@@ -174,7 +174,7 @@ class ConcreteExtensionAPI implements ExtensionAPI, IExtensionRuntime {
174
174
  }
175
175
 
176
176
  sendMessage<T = unknown>(
177
- message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">,
177
+ message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
178
178
  options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
179
179
  ): void {
180
180
  this.runtime.sendMessage(message, options);
@@ -824,7 +824,7 @@ export interface ToolResultEventResult {
824
824
  }
825
825
 
826
826
  export interface BeforeAgentStartEventResult {
827
- message?: Pick<CustomMessage, "customType" | "content" | "display" | "details">;
827
+ message?: Pick<CustomMessage, "customType" | "content" | "display" | "details" | "attribution">;
828
828
  /** Replace the system prompt for this turn. If multiple extensions return this, they are chained. */
829
829
  systemPrompt?: string;
830
830
  }
@@ -1015,7 +1015,7 @@ export interface ExtensionAPI {
1015
1015
 
1016
1016
  /** Send a custom message to the session. */
1017
1017
  sendMessage<T = unknown>(
1018
- message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">,
1018
+ message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
1019
1019
  options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
1020
1020
  ): void;
1021
1021
 
@@ -1184,7 +1184,7 @@ export interface ExtensionShortcut {
1184
1184
  type HandlerFn = (...args: unknown[]) => Promise<unknown>;
1185
1185
 
1186
1186
  export type SendMessageHandler = <T = unknown>(
1187
- message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">,
1187
+ message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
1188
1188
  options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
1189
1189
  ) => void;
1190
1190
 
@@ -23,7 +23,7 @@ type HandlerFn = (...args: unknown[]) => Promise<unknown>;
23
23
  * Send message handler type for pi.sendMessage().
24
24
  */
25
25
  export type SendMessageHandler = <T = unknown>(
26
- message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
26
+ message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
27
27
  options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" },
28
28
  ) => void;
29
29
 
@@ -585,7 +585,7 @@ export interface ToolResultEventResult {
585
585
  */
586
586
  export interface BeforeAgentStartEventResult {
587
587
  /** Message to inject into context (persisted to session, visible in TUI) */
588
- message?: Pick<HookMessage, "customType" | "content" | "display" | "details">;
588
+ message?: Pick<HookMessage, "customType" | "content" | "display" | "details" | "attribution">;
589
589
  }
590
590
 
591
591
  /** Return type for session_before_switch handlers */
@@ -733,12 +733,13 @@ export interface HookAPI {
733
733
  * @param message.content - Message content (string or TextContent/ImageContent array)
734
734
  * @param message.display - Whether to show in TUI (true = styled display, false = hidden)
735
735
  * @param message.details - Optional hook-specific metadata (not sent to LLM)
736
+ * @param message.attribution - Who initiated the message for billing/attribution semantics ("user" | "agent")
736
737
  * @param options.triggerTurn - If true and agent is idle, triggers a new LLM turn. Default: false.
737
738
  * If agent is streaming, message is queued and triggerTurn is ignored.
738
739
  * @param options.deliverAs - How to deliver the message: "steer" or "followUp".
739
740
  */
740
741
  sendMessage<T = unknown>(
741
- message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
742
+ message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
742
743
  options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" },
743
744
  ): void;
744
745
 
@@ -3,9 +3,10 @@
3
3
  */
4
4
 
5
5
  import { sanitizeText } from "@oh-my-pi/pi-natives";
6
- import { Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
6
+ import { Container, ImageProtocol, Loader, Spacer, TERMINAL, Text, type TUI } from "@oh-my-pi/pi-tui";
7
7
  import { getSymbolTheme, theme } from "../../modes/theme/theme";
8
8
  import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
9
+ import { getSixelLineMask, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
9
10
  import { DynamicBorder } from "./dynamic-border";
10
11
  import { truncateToVisualLines } from "./visual-truncate";
11
12
 
@@ -75,18 +76,18 @@ export class BashExecutionComponent extends Container {
75
76
  }
76
77
 
77
78
  appendOutput(chunk: string): void {
78
- const clean = sanitizeText(chunk);
79
+ const clean = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText);
79
80
 
80
81
  // Append to output lines
81
- const newLines = clean.split("\n").map(line => this.#clampDisplayLine(line));
82
- if (this.#outputLines.length > 0 && newLines.length > 0) {
83
- // Append first chunk to last line (incomplete line continuation)
84
- this.#outputLines[this.#outputLines.length - 1] = this.#clampDisplayLine(
85
- `${this.#outputLines[this.#outputLines.length - 1]}${newLines[0]}`,
86
- );
87
- this.#outputLines.push(...newLines.slice(1));
82
+ const incomingLines = clean.split("\n");
83
+ if (this.#outputLines.length > 0 && incomingLines.length > 0) {
84
+ const lastIndex = this.#outputLines.length - 1;
85
+ const mergedLines = [`${this.#outputLines[lastIndex]}${incomingLines[0]}`, ...incomingLines.slice(1)];
86
+ const clampedMergedLines = this.#clampLinesPreservingSixel(mergedLines);
87
+ this.#outputLines[lastIndex] = clampedMergedLines[0] ?? "";
88
+ this.#outputLines.push(...clampedMergedLines.slice(1));
88
89
  } else {
89
- this.#outputLines.push(...newLines);
90
+ this.#outputLines.push(...this.#clampLinesPreservingSixel(incomingLines));
90
91
  }
91
92
 
92
93
  this.#updateDisplay();
@@ -120,6 +121,9 @@ export class BashExecutionComponent extends Container {
120
121
  // Apply preview truncation based on expanded state
121
122
  const previewLogicalLines = availableLines.slice(-PREVIEW_LINES);
122
123
  const hiddenLineCount = availableLines.length - previewLogicalLines.length;
124
+ const sixelLineMask =
125
+ TERMINAL.imageProtocol === ImageProtocol.Sixel ? getSixelLineMask(availableLines) : undefined;
126
+ const hasSixelOutput = sixelLineMask?.some(Boolean) ?? false;
123
127
 
124
128
  // Rebuild content container
125
129
  this.#contentContainer.clear();
@@ -130,9 +134,10 @@ export class BashExecutionComponent extends Container {
130
134
 
131
135
  // Output
132
136
  if (availableLines.length > 0) {
133
- if (this.#expanded) {
134
- // Show all lines
135
- const displayText = availableLines.map(line => theme.fg("muted", line)).join("\n");
137
+ if (this.#expanded || hasSixelOutput) {
138
+ const displayText = availableLines
139
+ .map((line, index) => (sixelLineMask?.[index] ? line : theme.fg("muted", line)))
140
+ .join("\n");
136
141
  this.#contentContainer.addChild(new Text(`\n${displayText}`, 1, 0));
137
142
  } else {
138
143
  // Use shared visual truncation utility, recomputed per render width
@@ -155,7 +160,7 @@ export class BashExecutionComponent extends Container {
155
160
  const statusParts: string[] = [];
156
161
 
157
162
  // Show how many lines are hidden (collapsed preview)
158
- if (hiddenLineCount > 0) {
163
+ if (hiddenLineCount > 0 && !hasSixelOutput) {
159
164
  statusParts.push(theme.fg("dim", `… ${hiddenLineCount} more lines (ctrl+o to expand)`));
160
165
  }
161
166
 
@@ -183,9 +188,18 @@ export class BashExecutionComponent extends Container {
183
188
  return `${line.slice(0, MAX_DISPLAY_LINE_CHARS)}… [${omitted} chars omitted]`;
184
189
  }
185
190
 
191
+ #clampLinesPreservingSixel(lines: string[]): string[] {
192
+ if (lines.length === 0) return [];
193
+ const sixelLineMask = getSixelLineMask(lines);
194
+ if (!sixelLineMask.some(Boolean)) {
195
+ return lines.map(line => this.#clampDisplayLine(line));
196
+ }
197
+ return lines.map((line, index) => (sixelLineMask[index] ? line : this.#clampDisplayLine(line)));
198
+ }
199
+
186
200
  #setOutput(output: string): void {
187
- const clean = sanitizeText(output);
188
- this.#outputLines = clean ? clean.split("\n").map(line => this.#clampDisplayLine(line)) : [];
201
+ const clean = sanitizeWithOptionalSixelPassthrough(output, sanitizeText);
202
+ this.#outputLines = clean ? this.#clampLinesPreservingSixel(clean.split("\n")) : [];
189
203
  }
190
204
 
191
205
  /**
@@ -34,6 +34,7 @@ import { formatExpandHint, truncateToWidth } from "../../tools/render-utils";
34
34
  import { toolRenderers } from "../../tools/renderers";
35
35
  import { renderStatusLine } from "../../tui";
36
36
  import { convertToPng } from "../../utils/image-convert";
37
+ import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
37
38
  import { renderDiff } from "./diff";
38
39
 
39
40
  function ensureInvalidate(component: unknown): Component {
@@ -589,7 +590,7 @@ export class ToolExecutionComponent extends Container {
589
590
 
590
591
  let output = textBlocks
591
592
  .map((c: any) => {
592
- return sanitizeText(c.text || "");
593
+ return sanitizeWithOptionalSixelPassthrough(c.text || "", sanitizeText);
593
594
  })
594
595
  .join("\n");
595
596
 
@@ -225,6 +225,7 @@ export class InputController {
225
225
  content: message,
226
226
  display: true,
227
227
  details,
228
+ attribution: "user",
228
229
  },
229
230
  { streamingBehavior: "followUp" },
230
231
  );
package/src/sdk.ts CHANGED
@@ -763,6 +763,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
763
763
  customType: "async-result",
764
764
  content: message,
765
765
  display: true,
766
+ attribution: "agent",
766
767
  details: {
767
768
  jobId,
768
769
  type: job?.type,
@@ -422,6 +422,7 @@ export class AgentSession {
422
422
  content: reminderText,
423
423
  display: false,
424
424
  details: { toolName: action.sourceToolName },
425
+ attribution: "agent",
425
426
  timestamp: Date.now(),
426
427
  });
427
428
  });
@@ -597,6 +598,7 @@ export class AgentSession {
597
598
  content: injection.content,
598
599
  display: false,
599
600
  details,
601
+ attribution: "agent",
600
602
  timestamp: Date.now(),
601
603
  });
602
604
  this.sessionManager.appendCustomMessageEntry(
@@ -604,6 +606,7 @@ export class AgentSession {
604
606
  injection.content,
605
607
  false,
606
608
  details,
609
+ "agent",
607
610
  );
608
611
  this.#markTtsrInjected(details.rules);
609
612
  }
@@ -642,6 +645,7 @@ export class AgentSession {
642
645
  event.message.content,
643
646
  event.message.display,
644
647
  event.message.details,
648
+ event.message.attribution ?? "agent",
645
649
  );
646
650
  if (event.message.role === "custom" && event.message.customType === "ttsr-injection") {
647
651
  this.#markTtsrInjected(this.#extractTtsrRuleNames(event.message.details));
@@ -1011,6 +1015,7 @@ export class AgentSession {
1011
1015
  content: injection.content,
1012
1016
  display: false,
1013
1017
  details: { rules: injection.rules.map(rule => rule.name) },
1018
+ attribution: "agent",
1014
1019
  timestamp: Date.now(),
1015
1020
  });
1016
1021
  this.#ensureTtsrResumePromise();
@@ -1809,6 +1814,7 @@ export class AgentSession {
1809
1814
  customType: "plan-mode-reference",
1810
1815
  content,
1811
1816
  display: false,
1817
+ attribution: "agent",
1812
1818
  timestamp: Date.now(),
1813
1819
  };
1814
1820
  }
@@ -1849,6 +1855,7 @@ export class AgentSession {
1849
1855
  customType: "plan-mode-context",
1850
1856
  content,
1851
1857
  display: false,
1858
+ attribution: "agent",
1852
1859
  timestamp: Date.now(),
1853
1860
  };
1854
1861
  }
@@ -1910,8 +1917,8 @@ export class AgentSession {
1910
1917
  }
1911
1918
 
1912
1919
  const message = options?.synthetic
1913
- ? { role: "developer" as const, content: userContent, timestamp: Date.now() }
1914
- : { role: "user" as const, content: userContent, timestamp: Date.now() };
1920
+ ? { role: "developer" as const, content: userContent, attribution: "agent" as const, timestamp: Date.now() }
1921
+ : { role: "user" as const, content: userContent, attribution: "user" as const, timestamp: Date.now() };
1915
1922
 
1916
1923
  await this.#promptWithMessage(message, expandedText, options);
1917
1924
  if (!options?.synthetic) {
@@ -1920,7 +1927,7 @@ export class AgentSession {
1920
1927
  }
1921
1928
 
1922
1929
  async promptCustomMessage<T = unknown>(
1923
- message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">,
1930
+ message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
1924
1931
  options?: Pick<PromptOptions, "streamingBehavior" | "toolChoice">,
1925
1932
  ): Promise<void> {
1926
1933
  const textContent =
@@ -1945,6 +1952,7 @@ export class AgentSession {
1945
1952
  content: message.content,
1946
1953
  display: message.display,
1947
1954
  details: message.details,
1955
+ attribution: message.attribution ?? "agent",
1948
1956
  timestamp: Date.now(),
1949
1957
  };
1950
1958
 
@@ -2033,6 +2041,8 @@ export class AgentSession {
2033
2041
  this.#baseSystemPrompt,
2034
2042
  );
2035
2043
  if (result?.messages) {
2044
+ const promptAttribution: "user" | "agent" | undefined =
2045
+ "attribution" in message ? message.attribution : undefined;
2036
2046
  for (const msg of result.messages) {
2037
2047
  messages.push({
2038
2048
  role: "custom",
@@ -2040,6 +2050,7 @@ export class AgentSession {
2040
2050
  content: msg.content,
2041
2051
  display: msg.display,
2042
2052
  details: msg.details,
2053
+ attribution: msg.attribution ?? promptAttribution ?? (message.role === "user" ? "user" : "agent"),
2043
2054
  timestamp: Date.now(),
2044
2055
  });
2045
2056
  }
@@ -2239,6 +2250,7 @@ export class AgentSession {
2239
2250
  this.agent.steer({
2240
2251
  role: "user",
2241
2252
  content,
2253
+ attribution: "user",
2242
2254
  timestamp: Date.now(),
2243
2255
  });
2244
2256
  }
@@ -2256,6 +2268,7 @@ export class AgentSession {
2256
2268
  this.agent.followUp({
2257
2269
  role: "user",
2258
2270
  content,
2271
+ attribution: "user",
2259
2272
  timestamp: Date.now(),
2260
2273
  });
2261
2274
  }
@@ -2286,7 +2299,7 @@ export class AgentSession {
2286
2299
  * - Not streaming + no trigger: appends to state/session, no turn
2287
2300
  */
2288
2301
  async sendCustomMessage<T = unknown>(
2289
- message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">,
2302
+ message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
2290
2303
  options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
2291
2304
  ): Promise<void> {
2292
2305
  const appMessage: CustomMessage<T> = {
@@ -2295,6 +2308,7 @@ export class AgentSession {
2295
2308
  content: message.content,
2296
2309
  display: message.display,
2297
2310
  details: message.details,
2311
+ attribution: message.attribution ?? "agent",
2298
2312
  timestamp: Date.now(),
2299
2313
  };
2300
2314
  if (this.isStreaming) {
@@ -2322,6 +2336,7 @@ export class AgentSession {
2322
2336
  message.content,
2323
2337
  message.display,
2324
2338
  message.details,
2339
+ message.attribution ?? "agent",
2325
2340
  );
2326
2341
  }
2327
2342
 
@@ -3212,7 +3227,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
3212
3227
 
3213
3228
  // Inject the handoff document as a custom message
3214
3229
  const handoffContent = `<handoff-context>\n${handoffText}\n</handoff-context>\n\nThe above is a handoff document from a previous session. Use this context to continue the work seamlessly.`;
3215
- this.sessionManager.appendCustomMessageEntry("handoff", handoffContent, true);
3230
+ this.sessionManager.appendCustomMessageEntry("handoff", handoffContent, true, undefined, "agent");
3216
3231
 
3217
3232
  // Rebuild agent messages from session
3218
3233
  const sessionContext = this.sessionManager.buildSessionContext();
@@ -3309,6 +3324,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
3309
3324
  this.agent.appendMessage({
3310
3325
  role: "developer",
3311
3326
  content: [{ type: "text", text: reminder }],
3327
+ attribution: "agent",
3312
3328
  timestamp: Date.now(),
3313
3329
  });
3314
3330
  this.#scheduleAgentContinue({ generation: this.#promptGeneration });
@@ -3339,9 +3355,10 @@ Be thorough - include exact file paths, function names, error messages, and tech
3339
3355
  content: report,
3340
3356
  display: false,
3341
3357
  details,
3358
+ attribution: "agent",
3342
3359
  timestamp: Date.now(),
3343
3360
  });
3344
- this.sessionManager.appendCustomMessageEntry("rewind-report", report, false, details);
3361
+ this.sessionManager.appendCustomMessageEntry("rewind-report", report, false, details, "agent");
3345
3362
  this.#checkpointState = undefined;
3346
3363
  this.#pendingRewindReport = undefined;
3347
3364
  }
@@ -3460,6 +3477,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
3460
3477
  this.agent.appendMessage({
3461
3478
  role: "developer",
3462
3479
  content: [{ type: "text", text: reminder }],
3480
+ attribution: "agent",
3463
3481
  timestamp: Date.now(),
3464
3482
  });
3465
3483
  this.#scheduleAgentContinue({ generation: this.#promptGeneration });
@@ -3876,6 +3894,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
3876
3894
  {
3877
3895
  role: "developer",
3878
3896
  content: [{ type: "text", text: "Continue if you have next steps." }],
3897
+ attribution: "agent",
3879
3898
  timestamp: Date.now(),
3880
3899
  },
3881
3900
  "Continue if you have next steps.",
@@ -149,7 +149,14 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
149
149
  return entry.message;
150
150
 
151
151
  case "custom_message":
152
- return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
152
+ return createCustomMessage(
153
+ entry.customType,
154
+ entry.content,
155
+ entry.display,
156
+ entry.details,
157
+ entry.timestamp,
158
+ entry.attribution,
159
+ );
153
160
 
154
161
  case "branch_summary":
155
162
  return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
@@ -81,7 +81,14 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
81
81
  return entry.message;
82
82
  }
83
83
  if (entry.type === "custom_message") {
84
- return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
84
+ return createCustomMessage(
85
+ entry.customType,
86
+ entry.content,
87
+ entry.display,
88
+ entry.details,
89
+ entry.timestamp,
90
+ entry.attribution,
91
+ );
85
92
  }
86
93
  if (entry.type === "branch_summary") {
87
94
  return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
@@ -5,7 +5,7 @@
5
5
  * and provides a transformer to convert them to LLM-compatible messages.
6
6
  */
7
7
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
8
- import type { ImageContent, Message, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
8
+ import type { ImageContent, Message, MessageAttribution, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
9
9
  import { renderPromptTemplate } from "../config/prompt-templates";
10
10
  import branchSummaryContextPrompt from "../prompts/compaction/branch-summary-context.md" with { type: "text" };
11
11
  import compactionSummaryContextPrompt from "../prompts/compaction/compaction-summary-context.md" with { type: "text" };
@@ -75,6 +75,8 @@ export interface CustomMessage<T = unknown> {
75
75
  content: string | (TextContent | ImageContent)[];
76
76
  display: boolean;
77
77
  details?: T;
78
+ /** Who initiated this message for billing/attribution semantics. */
79
+ attribution?: MessageAttribution;
78
80
  timestamp: number;
79
81
  }
80
82
 
@@ -87,6 +89,8 @@ export interface HookMessage<T = unknown> {
87
89
  content: string | (TextContent | ImageContent)[];
88
90
  display: boolean;
89
91
  details?: T;
92
+ /** Who initiated this message for billing/attribution semantics. */
93
+ attribution?: MessageAttribution;
90
94
  timestamp: number;
91
95
  }
92
96
 
@@ -206,6 +210,7 @@ export function createCustomMessage(
206
210
  display: boolean,
207
211
  details: unknown | undefined,
208
212
  timestamp: string,
213
+ attribution?: MessageAttribution,
209
214
  ): CustomMessage {
210
215
  return {
211
216
  role: "custom",
@@ -213,6 +218,7 @@ export function createCustomMessage(
213
218
  content,
214
219
  display,
215
220
  details,
221
+ attribution,
216
222
  timestamp: new Date(timestamp).getTime(),
217
223
  };
218
224
  }
@@ -236,6 +242,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
236
242
  return {
237
243
  role: "user",
238
244
  content: [{ type: "text", text: bashExecutionToText(m) }],
245
+ attribution: "user",
239
246
  timestamp: m.timestamp,
240
247
  };
241
248
  case "pythonExecution":
@@ -245,14 +252,18 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
245
252
  return {
246
253
  role: "user",
247
254
  content: [{ type: "text", text: pythonExecutionToText(m) }],
255
+ attribution: "user",
248
256
  timestamp: m.timestamp,
249
257
  };
250
258
  case "custom":
251
259
  case "hookMessage": {
252
260
  const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content;
261
+ const role = "user";
262
+ const attribution = m.attribution;
253
263
  return {
254
- role: "user",
264
+ role,
255
265
  content,
266
+ attribution,
256
267
  timestamp: m.timestamp,
257
268
  };
258
269
  }
@@ -265,6 +276,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
265
276
  text: renderPromptTemplate(BRANCH_SUMMARY_TEMPLATE, { summary: m.summary }),
266
277
  },
267
278
  ],
279
+ attribution: "agent",
268
280
  timestamp: m.timestamp,
269
281
  };
270
282
  case "compactionSummary":
@@ -276,6 +288,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
276
288
  text: renderPromptTemplate(COMPACTION_SUMMARY_TEMPLATE, { summary: m.summary }),
277
289
  },
278
290
  ],
291
+ attribution: "agent",
279
292
  timestamp: m.timestamp,
280
293
  };
281
294
  case "fileMention": {
@@ -296,17 +309,21 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
296
309
  return {
297
310
  role: "user",
298
311
  content,
312
+ attribution: "user",
299
313
  timestamp: m.timestamp,
300
314
  };
301
315
  }
302
316
  case "user":
317
+ return { ...m, attribution: m.attribution ?? "user" };
303
318
  case "developer":
319
+ return { ...m, attribution: m.attribution ?? "agent" };
304
320
  case "assistant":
305
321
  return m;
306
322
  case "toolResult":
307
323
  return {
308
324
  ...m,
309
325
  content: getPrunedToolResultContent(m as ToolResultMessage),
326
+ attribution: m.attribution ?? "agent",
310
327
  };
311
328
  default:
312
329
  // biome-ignore lint/correctness/noSwitchDeclarations: fine
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
5
- import type { ImageContent, Message, TextContent, Usage } from "@oh-my-pi/pi-ai";
5
+ import type { ImageContent, Message, MessageAttribution, TextContent, Usage } from "@oh-my-pi/pi-ai";
6
6
  import { getTerminalId } from "@oh-my-pi/pi-tui";
7
7
  import {
8
8
  getBlobsDir,
@@ -151,7 +151,7 @@ export interface ModeChangeEntry extends SessionEntryBase {
151
151
  * Use customType to identify your extension's entries.
152
152
  *
153
153
  * Unlike CustomEntry, this DOES participate in LLM context.
154
- * The content is converted to a user message in buildSessionContext().
154
+ * The content participates in LLM context through convertToLlm().
155
155
  * Use details for extension-specific metadata (not sent to LLM).
156
156
  *
157
157
  * display controls TUI rendering:
@@ -164,6 +164,8 @@ export interface CustomMessageEntry<T = unknown> extends SessionEntryBase {
164
164
  content: string | (TextContent | ImageContent)[];
165
165
  details?: T;
166
166
  display: boolean;
167
+ /** Who initiated this message for billing/attribution semantics. */
168
+ attribution?: MessageAttribution;
167
169
  }
168
170
 
169
171
  /** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */
@@ -483,7 +485,14 @@ export function buildSessionContext(
483
485
  messages.push(entry.message);
484
486
  } else if (entry.type === "custom_message") {
485
487
  messages.push(
486
- createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp),
488
+ createCustomMessage(
489
+ entry.customType,
490
+ entry.content,
491
+ entry.display,
492
+ entry.details,
493
+ entry.timestamp,
494
+ entry.attribution,
495
+ ),
487
496
  );
488
497
  } else if (entry.type === "branch_summary" && entry.summary) {
489
498
  messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
@@ -1825,6 +1834,7 @@ export class SessionManager {
1825
1834
  * @param content Message content (string or TextContent/ImageContent array)
1826
1835
  * @param display Whether to show in TUI (true = styled display, false = hidden)
1827
1836
  * @param details Optional extension-specific metadata (not sent to LLM)
1837
+ * @param attribution Who initiated this message for billing/attribution semantics
1828
1838
  * @returns Entry id
1829
1839
  */
1830
1840
  appendCustomMessageEntry<T = unknown>(
@@ -1832,6 +1842,7 @@ export class SessionManager {
1832
1842
  content: string | (TextContent | ImageContent)[],
1833
1843
  display: boolean,
1834
1844
  details?: T,
1845
+ attribution: MessageAttribution = "agent",
1835
1846
  ): string {
1836
1847
  const entry: CustomMessageEntry<T> = {
1837
1848
  type: "custom_message",
@@ -1839,6 +1850,7 @@ export class SessionManager {
1839
1850
  content,
1840
1851
  display,
1841
1852
  details,
1853
+ attribution,
1842
1854
  id: generateId(this.#byId),
1843
1855
  parentId: this.#leafId,
1844
1856
  timestamp: new Date().toISOString(),
@@ -1,5 +1,6 @@
1
1
  import { sanitizeText } from "@oh-my-pi/pi-natives";
2
2
  import { formatBytes } from "../tools/render-utils";
3
+ import { sanitizeWithOptionalSixelPassthrough } from "../utils/sixel";
3
4
 
4
5
  // =============================================================================
5
6
  // Constants
@@ -571,7 +572,7 @@ export class OutputSink {
571
572
  }
572
573
 
573
574
  async push(chunk: string): Promise<void> {
574
- chunk = sanitizeText(chunk);
575
+ chunk = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText);
575
576
  this.#onChunk?.(chunk);
576
577
 
577
578
  const dataBytes = Buffer.byteLength(chunk, "utf-8");
@@ -14,6 +14,7 @@ import xterm from "@xterm/headless";
14
14
  import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
15
15
  import type { Theme } from "../modes/theme/theme";
16
16
  import { OutputSink, type OutputSummary } from "../session/streaming-output";
17
+ import { sanitizeWithOptionalSixelPassthrough } from "../utils/sixel";
17
18
  import { formatStatusIcon, replaceTabs } from "./render-utils";
18
19
 
19
20
  export interface BashInteractiveResult extends OutputSummary {
@@ -24,7 +25,7 @@ export interface BashInteractiveResult extends OutputSummary {
24
25
 
25
26
  function normalizeCaptureChunk(chunk: string): string {
26
27
  const normalized = chunk.replace(/\r\n/gu, "\n").replace(/\r/gu, "\n");
27
- return sanitizeText(normalized);
28
+ return sanitizeWithOptionalSixelPassthrough(normalized, sanitizeText);
28
29
  }
29
30
 
30
31
  const XtermTerminal = xterm.Terminal;
package/src/tools/bash.ts CHANGED
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
4
4
  import type { Component } from "@oh-my-pi/pi-tui";
5
- import { Text } from "@oh-my-pi/pi-tui";
5
+ import { ImageProtocol, TERMINAL, Text } from "@oh-my-pi/pi-tui";
6
6
  import { $env, getProjectDir, isEnoent } from "@oh-my-pi/pi-utils";
7
7
  import { Type } from "@sinclair/typebox";
8
8
  import { renderPromptTemplate } from "../config/prompt-templates";
@@ -15,6 +15,7 @@ import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
15
15
  import { DEFAULT_MAX_BYTES, TailBuffer } from "../session/streaming-output";
16
16
  import { renderStatusLine } from "../tui";
17
17
  import { CachedOutputBlock } from "../tui/output-block";
18
+ import { getSixelLineMask } from "../utils/sixel";
18
19
  import type { ToolSession } from ".";
19
20
  import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-interactive";
20
21
  import { checkBashInterception } from "./bash-interceptor";
@@ -414,14 +415,21 @@ export const bashToolRenderer = {
414
415
 
415
416
  const outputLines: string[] = [];
416
417
  const hasOutput = displayOutput.trim().length > 0;
418
+ const rawOutputLines = displayOutput.split("\n");
419
+ const sixelLineMask =
420
+ TERMINAL.imageProtocol === ImageProtocol.Sixel ? getSixelLineMask(rawOutputLines) : undefined;
421
+ const hasSixelOutput = sixelLineMask?.some(Boolean) ?? false;
417
422
  if (hasOutput) {
418
- if (expanded) {
423
+ if (hasSixelOutput) {
419
424
  outputLines.push(
420
- ...displayOutput.split("\n").map(line => uiTheme.fg("toolOutput", replaceTabs(line))),
425
+ ...rawOutputLines.map((line, index) =>
426
+ sixelLineMask?.[index] ? line : uiTheme.fg("toolOutput", replaceTabs(line)),
427
+ ),
421
428
  );
429
+ } else if (expanded) {
430
+ outputLines.push(...rawOutputLines.map(line => uiTheme.fg("toolOutput", replaceTabs(line))));
422
431
  } else {
423
- const styledOutput = displayOutput
424
- .split("\n")
432
+ const styledOutput = rawOutputLines
425
433
  .map(line => uiTheme.fg("toolOutput", replaceTabs(line)))
426
434
  .join("\n");
427
435
  const textContent = styledOutput;
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Bordered output container with optional header and sections.
3
3
  */
4
- import { padding, visibleWidth } from "@oh-my-pi/pi-tui";
4
+ import { ImageProtocol, padding, TERMINAL, visibleWidth } from "@oh-my-pi/pi-tui";
5
5
  import type { Theme } from "../modes/theme/theme";
6
+ import { getSixelLineMask } from "../utils/sixel";
6
7
  import type { State } from "./types";
7
8
  import type { RenderCache } from "./utils";
8
9
  import { getStateBgColor, Hasher, padToWidth, truncateToWidth } from "./utils";
@@ -80,7 +81,13 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
80
81
  );
81
82
  }
82
83
  const allLines = section.lines.flatMap(l => l.split("\n"));
83
- for (const line of allLines) {
84
+ const sixelLineMask = TERMINAL.imageProtocol === ImageProtocol.Sixel ? getSixelLineMask(allLines) : undefined;
85
+ for (let lineIndex = 0; lineIndex < allLines.length; lineIndex++) {
86
+ const line = allLines[lineIndex]!;
87
+ if (sixelLineMask?.[lineIndex]) {
88
+ lines.push(line);
89
+ continue;
90
+ }
84
91
  // Sections may receive content that was already padded to terminal width
85
92
  // (e.g. from Text.render()). Trailing spaces would trigger truncateToWidth()
86
93
  // to append an ellipsis even when the *semantic* content fits.
@@ -0,0 +1,69 @@
1
+ import { $env } from "@oh-my-pi/pi-utils";
2
+
3
+ const SIXEL_START_REGEX = /\x1bP(?:[0-9;]*)q/u;
4
+ const SIXEL_END_SEQUENCE = "\x1b\\";
5
+ const SIXEL_END_BELL = "\x07";
6
+ const SIXEL_SEQUENCE_REGEX = /\x1bP(?:[0-9;]*)q[\s\S]*?(?:\x1b\\|\x07)/gu;
7
+ const SIXEL_PLACEHOLDER_PREFIX = "__OMP_SIXEL_SEQUENCE_";
8
+
9
+ /**
10
+ * Returns whether SIXEL passthrough is explicitly enabled.
11
+ *
12
+ * Both gates must be enabled to preserve SIXEL control sequences:
13
+ * - PI_FORCE_IMAGE_PROTOCOL=sixel
14
+ * - PI_ALLOW_SIXEL_PASSTHROUGH=1
15
+ */
16
+ export function isSixelPassthroughEnabled(): boolean {
17
+ const forcedProtocol = $env.PI_FORCE_IMAGE_PROTOCOL?.trim().toLowerCase();
18
+ return forcedProtocol === "sixel" && $env.PI_ALLOW_SIXEL_PASSTHROUGH === "1";
19
+ }
20
+ /** Returns true when the text contains a SIXEL start sequence. */
21
+ export function containsSixelSequence(text: string): boolean {
22
+ return SIXEL_START_REGEX.test(text);
23
+ }
24
+
25
+ /**
26
+ * Returns a boolean mask indicating which lines belong to a SIXEL sequence block.
27
+ * Supports multi-line SIXEL payloads generated by libsixel.
28
+ */
29
+ export function getSixelLineMask(lines: string[]): boolean[] {
30
+ let inSequence = false;
31
+ return lines.map(line => {
32
+ const hasStart = containsSixelSequence(line);
33
+ if (hasStart) {
34
+ inSequence = true;
35
+ }
36
+ const isSixelLine = inSequence;
37
+ if (inSequence && (line.includes(SIXEL_END_SEQUENCE) || line.includes(SIXEL_END_BELL))) {
38
+ inSequence = false;
39
+ }
40
+ return isSixelLine;
41
+ });
42
+ }
43
+
44
+ /** Returns true when the line contains a SIXEL start sequence. */
45
+ export function isSixelLine(line: string): boolean {
46
+ return containsSixelSequence(line);
47
+ }
48
+
49
+ /**
50
+ * Sanitizes text while preserving embedded SIXEL sequences when passthrough is enabled.
51
+ */
52
+ export function sanitizeWithOptionalSixelPassthrough(text: string, sanitize: (text: string) => string): string {
53
+ if (!isSixelPassthroughEnabled() || !containsSixelSequence(text)) {
54
+ return sanitize(text);
55
+ }
56
+
57
+ const preservedSequences: string[] = [];
58
+ const tokenized = text.replace(SIXEL_SEQUENCE_REGEX, match => {
59
+ const token = `${SIXEL_PLACEHOLDER_PREFIX}${preservedSequences.length}__`;
60
+ preservedSequences.push(match);
61
+ return token;
62
+ });
63
+
64
+ const sanitized = sanitize(tokenized);
65
+ return sanitized.replace(/__OMP_SIXEL_SEQUENCE_(\d+)__/gu, (_, indexText: string) => {
66
+ const index = Number.parseInt(indexText, 10);
67
+ return preservedSequences[index] ?? "";
68
+ });
69
+ }
@@ -66,6 +66,7 @@ function buildSystemBlocks(
66
66
  return buildAnthropicSystemBlocks(systemPrompt, {
67
67
  includeClaudeCodeInstruction: includeClaudeCode,
68
68
  extraInstructions,
69
+ cacheControl: { type: "ephemeral" },
69
70
  });
70
71
  }
71
72