@timurproko/a1 0.1.8-dev.368 → 0.1.8-dev.402

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.
Files changed (87) hide show
  1. package/dist/composition/owned-ui.d.ts +2 -0
  2. package/dist/composition/owned-ui.js +28 -11
  3. package/dist/contracts/owned-ui/image-attachments.d.ts +7 -0
  4. package/dist/contracts/owned-ui/image-attachments.js +7 -0
  5. package/dist/foundation/release/dependency-certification.js +65 -7
  6. package/dist/integrations/pi/components/owned-editor-ux.d.ts +8 -6
  7. package/dist/integrations/pi/components/owned-editor-ux.js +115 -26
  8. package/dist/integrations/pi/components/shell-editor-autocomplete.js +4 -0
  9. package/dist/integrations/pi/components/shell-presenters-transcript.js +15 -5
  10. package/dist/integrations/pi/components/shell-shared-facade.d.ts +14 -5
  11. package/dist/integrations/pi/components/tool-image-presentation.d.ts +21 -0
  12. package/dist/integrations/pi/components/tool-image-presentation.js +86 -0
  13. package/dist/integrations/pi/components/tool-result-adapter.d.ts +2 -2
  14. package/dist/integrations/pi/components/transcript-image-resolver.d.ts +7 -0
  15. package/dist/integrations/pi/components/transcript-image-resolver.js +19 -0
  16. package/dist/integrations/pi/components/upstream/components/scoped-models-selector.d.ts +1 -1
  17. package/dist/integrations/pi/components/upstream/components/scoped-models-selector.js +3 -1
  18. package/dist/integrations/pi/components/upstream/components/tool-execution.d.ts +61 -0
  19. package/dist/integrations/pi/components/upstream/components/tool-execution.js +344 -0
  20. package/dist/integrations/pi/engine/adapter.d.ts +4 -1
  21. package/dist/integrations/pi/engine/adapter.js +66 -28
  22. package/dist/integrations/pi/engine/pending-delivery.d.ts +4 -0
  23. package/dist/integrations/pi/engine/pending-delivery.js +15 -6
  24. package/dist/integrations/pi/engine/tool-rendering.js +26 -3
  25. package/dist/integrations/pi/engine/transcript-image-assets.d.ts +11 -0
  26. package/dist/integrations/pi/engine/transcript-image-assets.js +86 -0
  27. package/dist/integrations/pi/session-ui/clipboard-diagnostics.d.ts +16 -0
  28. package/dist/integrations/pi/session-ui/clipboard-diagnostics.js +65 -0
  29. package/dist/integrations/pi/session-ui/editor-hyperlink-budget.d.ts +9 -0
  30. package/dist/integrations/pi/session-ui/editor-hyperlink-budget.js +23 -0
  31. package/dist/integrations/pi/session-ui/index.d.ts +1 -0
  32. package/dist/integrations/pi/session-ui/index.js +1 -0
  33. package/dist/integrations/pi/session-ui/paste-executor.d.ts +10 -0
  34. package/dist/integrations/pi/session-ui/paste-executor.js +232 -0
  35. package/dist/integrations/pi/session-ui/paste-helper.d.ts +1 -0
  36. package/dist/integrations/pi/session-ui/paste-helper.js +173 -0
  37. package/dist/integrations/pi/session-ui/paste-preparation-client.d.ts +16 -0
  38. package/dist/integrations/pi/session-ui/paste-preparation-client.js +159 -0
  39. package/dist/integrations/pi/session-ui/paste-protocol.d.ts +78 -0
  40. package/dist/integrations/pi/session-ui/paste-protocol.js +18 -0
  41. package/dist/integrations/pi/session-ui/paste-text-preparation.d.ts +20 -0
  42. package/dist/integrations/pi/session-ui/paste-text-preparation.js +134 -0
  43. package/dist/integrations/pi/session-ui/paste-text-worker.d.ts +1 -0
  44. package/dist/integrations/pi/session-ui/paste-text-worker.js +12 -0
  45. package/dist/integrations/pi/session-ui/path-chip-presentation.d.ts +7 -0
  46. package/dist/integrations/pi/session-ui/path-chip-presentation.js +24 -0
  47. package/dist/integrations/pi/session-ui/prompt-chips.d.ts +8 -1
  48. package/dist/integrations/pi/session-ui/prompt-chips.js +129 -149
  49. package/dist/integrations/pi/session-ui/response-copy-coordinator.d.ts +27 -0
  50. package/dist/integrations/pi/session-ui/response-copy-coordinator.js +189 -0
  51. package/dist/integrations/pi/session-ui/response-copy-helper.d.ts +1 -0
  52. package/dist/integrations/pi/session-ui/response-copy-helper.js +122 -0
  53. package/dist/integrations/pi/session-ui/response-copy-protocol.d.ts +56 -0
  54. package/dist/integrations/pi/session-ui/response-copy-protocol.js +5 -0
  55. package/dist/integrations/pi/session-ui/response-copy-transport.d.ts +31 -0
  56. package/dist/integrations/pi/session-ui/response-copy-transport.js +187 -0
  57. package/dist/integrations/pi/session-ui/session-shell-root.d.ts +15 -6
  58. package/dist/integrations/pi/session-ui/session-shell-root.js +25 -6
  59. package/dist/integrations/pi/session-ui/session-shell.js +100 -28
  60. package/dist/integrations/pi/session-ui/session-viewport-controller.d.ts +2 -2
  61. package/dist/integrations/pi/session-ui/session-viewport-controller.js +4 -3
  62. package/dist/integrations/pi/session-ui/system-clipboard.d.ts +2 -2
  63. package/dist/integrations/pi/session-ui/system-clipboard.js +26 -11
  64. package/dist/integrations/pi/tui-runtime/adapter.js +3 -1
  65. package/dist/native/darwin-arm64/manifest.json +1 -1
  66. package/dist/native/linux-x64/manifest.json +1 -1
  67. package/dist/native/win32-x64/manifest.json +2 -2
  68. package/dist/native/win32-x64/process-guardian.exe +0 -0
  69. package/dist/product-identity.d.ts +1 -1
  70. package/dist/product-identity.js +1 -1
  71. package/dist/product-identity.json +1 -0
  72. package/dist/ui/components/index.d.ts +1 -0
  73. package/dist/ui/components/index.js +1 -0
  74. package/dist/ui/components/selection-copy.d.ts +24 -0
  75. package/dist/ui/components/selection-copy.js +39 -0
  76. package/dist/ui/components/spans.d.ts +5 -2
  77. package/dist/ui/components/spans.js +12 -3
  78. package/dist/ui/components/text-selection.js +2 -0
  79. package/dist/ui/components/text.js +1 -1
  80. package/dist/ui/components/transcript-viewport.d.ts +5 -0
  81. package/dist/ui/components/transcript-viewport.js +100 -23
  82. package/dist/ui/components/visible-hyperlinks.js +40 -8
  83. package/docs/architecture/response-copy-delivery.md +83 -0
  84. package/docs/architecture/tool-image-presentation.md +62 -0
  85. package/docs/ci-release-runbook.md +19 -7
  86. package/docs/openspec-archive-automation.md +124 -0
  87. package/package.json +2 -1
@@ -1,6 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import { createHash } from "node:crypto";
3
- import { existsSync } from "node:fs";
2
+ import { existsSync, realpathSync } from "node:fs";
4
3
  import { mkdir, rm, writeFile } from "node:fs/promises";
5
4
  import { tmpdir } from "node:os";
6
5
  import { dirname, join, resolve } from "node:path";
@@ -9,6 +8,7 @@ import { PRODUCT_IDENTITY } from "../../../product-identity.js";
9
8
  import { configureOwnedHttpDispatcher } from "./http-dispatcher.js";
10
9
  import { readPinnedCommandChangelog } from "./changelog.js";
11
10
  import { toolRenderingInput } from "./tool-rendering.js";
11
+ import { TranscriptImageAssets } from "./transcript-image-assets.js";
12
12
  import { copyToClipboard, CredentialSynchronizationError, DefaultPackageManager, getAgentDir, ProjectTrustStore, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
13
13
  import { OWNED_UI_EXTENSION_CONTRACT_VERSION, OWNED_UI_EXTENSION_RENDER_CALLBACKS, OWNED_UI_EXTENSION_UI_CALLBACKS, OWNED_UI_EXTENSION_UI_PROPERTIES, CONTEXTUAL_PROMPT_SUGGESTION_INSTRUCTION, acceptsTranscriptUpdate, transcriptToolState, assertOwnedUiCommand, assertOwnedUiExtensionUiPort, assertOwnedUiPromptSuggestionRequest, assertOwnedUiPromptSuggestionResult, assertOwnedUiSnapshot, normalizePromptSuggestionCandidate, } from "../../../contracts/owned-ui/index.js";
14
14
  import { PINNED_PI_SETTINGS_CALLBACKS, PINNED_PI_WORKFLOW_COMMAND_NAMES, } from "./workflows.js";
@@ -91,6 +91,7 @@ export class PiEngineAdapter {
91
91
  #sessionSelection;
92
92
  #sessionForkPrompt;
93
93
  #workflowHost;
94
+ #clipboardWriter;
94
95
  #workflowInteraction;
95
96
  #listeners = new Map();
96
97
  #runtime;
@@ -126,11 +127,11 @@ export class PiEngineAdapter {
126
127
  #messageBlockIds = new WeakMap();
127
128
  #messageFallbackIds = new Map();
128
129
  #toolBlockIds = new Map();
129
- #transcriptImageAssets = new Map();
130
+ #transcriptImageAssets = new TranscriptImageAssets();
130
131
  #usageCache;
131
132
  #nextBlockSequence = 0;
132
133
  #diagnostics = [];
133
- #eventQueue = new PendingEngineDelivery();
134
+ #eventQueue = new PendingEngineDelivery(event => this.#transcriptImageAssets.retainEvent(event));
134
135
  #eventQueueProcessing;
135
136
  #overload;
136
137
  #overloads = 0;
@@ -192,7 +193,7 @@ export class PiEngineAdapter {
192
193
  return this.#agentDir;
193
194
  }
194
195
  resolveTranscriptImage(assetId) {
195
- return this.#transcriptImageAssets.get(assetId) ?? null;
196
+ return this.#transcriptImageAssets.resolve(assetId);
196
197
  }
197
198
  currentSessionFile() {
198
199
  const value = this.#session?.sessionManager?.getSessionFile?.();
@@ -784,17 +785,25 @@ export class PiEngineAdapter {
784
785
  if (!runtime)
785
786
  throw new Error("engine runtime is unavailable");
786
787
  const cwd = resolve(this.#cwd);
787
- const parent = dirname(cwd);
788
+ let trustPath = cwd;
789
+ try {
790
+ trustPath = realpathSync(cwd);
791
+ }
792
+ catch {
793
+ // Compatibility: pinned Pi retains the resolved path when real-path lookup fails.
794
+ }
795
+ // Security: trust the target's parent, not the parent containing a directory alias.
796
+ const parent = dirname(trustPath);
788
797
  const trustStore = new ProjectTrustStore(this.#agentDir);
789
798
  const trustOptions = [
790
- { label: "Trust", trusted: true, updates: [{ path: cwd, decision: true }], savedPath: cwd },
791
- ...(parent === cwd ? [] : [{
799
+ { label: "Trust", trusted: true, updates: [{ path: trustPath, decision: true }], savedPath: trustPath },
800
+ ...(parent === trustPath ? [] : [{
792
801
  label: `Trust parent folder (${parent})`,
793
802
  trusted: true,
794
- updates: [{ path: parent, decision: true }, { path: cwd, decision: null }],
803
+ updates: [{ path: parent, decision: true }, { path: trustPath, decision: null }],
795
804
  savedPath: parent,
796
805
  }]),
797
- { label: "Do not trust", trusted: false, updates: [{ path: cwd, decision: false }], savedPath: cwd },
806
+ { label: "Do not trust", trusted: false, updates: [{ path: trustPath, decision: false }], savedPath: trustPath },
798
807
  ];
799
808
  return {
800
809
  cwd,
@@ -1019,8 +1028,19 @@ export class PiEngineAdapter {
1019
1028
  pinnedToolDefinition(toolName) {
1020
1029
  return this.#requireWorkflowSession().extensionRunner?.getToolDefinition?.(toolName);
1021
1030
  }
1031
+ /** Bind the owned UI's clipboard lifecycle without changing the comparison host. True means acknowledged delivery. */
1032
+ bindClipboardWriter(writer) {
1033
+ const previous = this.#clipboardWriter;
1034
+ this.#clipboardWriter = writer;
1035
+ return () => { if (this.#clipboardWriter === writer)
1036
+ this.#clipboardWriter = previous; };
1037
+ }
1038
+ /** Workflow and tree copying share the active owner's write fence and delivery acknowledgment. */
1022
1039
  async copyWorkflowText(text) {
1040
+ if (this.#clipboardWriter !== undefined)
1041
+ return this.#clipboardWriter(text);
1023
1042
  await this.#workflowHost.copyText(text);
1043
+ return true;
1024
1044
  }
1025
1045
  clearQueuedWorkflows() {
1026
1046
  const session = this.#requireWorkflowSession();
@@ -1422,8 +1442,8 @@ export class PiEngineAdapter {
1422
1442
  const text = requireCapability(session.getLastAssistantText, "getLastAssistantText").call(session);
1423
1443
  if (typeof text !== "string" || text.length === 0)
1424
1444
  return workflowResult(request.command, "failed", "No agent messages to copy yet.");
1425
- await this.#workflowHost.copyText(text);
1426
- return workflowResult(request.command, "completed", "Copied last agent message to clipboard");
1445
+ const acknowledged = await this.copyWorkflowText(text);
1446
+ return workflowResult(request.command, "completed", acknowledged ? "Copied last agent message to clipboard" : "Submitted last agent message to clipboard");
1427
1447
  }
1428
1448
  case "name": {
1429
1449
  const manager = session.sessionManager;
@@ -2056,15 +2076,20 @@ export class PiEngineAdapter {
2056
2076
  }
2057
2077
  // Performance: replacing the transcript rebuilds its index so block lookup stays constant-time.
2058
2078
  #setTranscript(blocks) {
2079
+ // Invariant: lazy delivery snapshots must freeze before their authoritative source can be removed.
2080
+ if (!this.#eventQueue.seal()) {
2081
+ this.#beginOverload();
2082
+ return;
2083
+ }
2084
+ for (const block of blocks)
2085
+ this.#transcriptImageAssets.retain(block);
2086
+ for (const block of this.#transcript)
2087
+ this.#transcriptImageAssets.release(block);
2059
2088
  this.#transcript = blocks;
2060
2089
  this.#transcriptIndex.clear();
2061
2090
  for (const [index, block] of blocks.entries())
2062
2091
  this.#transcriptIndex.set(block.id, index);
2063
- const retainedAssets = new Set(blocks.flatMap(block => block.imageReferences?.map(reference => reference.assetId) ?? []));
2064
- for (const assetId of this.#transcriptImageAssets.keys()) {
2065
- if (!retainedAssets.has(assetId))
2066
- this.#transcriptImageAssets.delete(assetId);
2067
- }
2092
+ this.#transcriptImageAssets.discardUnowned();
2068
2093
  this.#transcriptSnapshot = undefined;
2069
2094
  }
2070
2095
  #transcriptBlock(id) {
@@ -2099,6 +2124,14 @@ export class PiEngineAdapter {
2099
2124
  this.#emitEvent({ type: "status", status: this.#status });
2100
2125
  }
2101
2126
  #handlePiEvent(event) {
2127
+ try {
2128
+ this.#applyPiEvent(event);
2129
+ }
2130
+ finally {
2131
+ this.#transcriptImageAssets.discardUnowned();
2132
+ }
2133
+ }
2134
+ #applyPiEvent(event) {
2102
2135
  if (!isRecord(event) || typeof event.type !== "string")
2103
2136
  return;
2104
2137
  // Invariant: usage moves at message and lifecycle boundaries, not with stream chunks, so the
@@ -2558,16 +2591,11 @@ export class PiEngineAdapter {
2558
2591
  return [];
2559
2592
  const references = [];
2560
2593
  for (const item of content) {
2561
- if (references.length >= 16 || !isRecord(item) || item.type !== "image"
2562
- || typeof item.data !== "string" || item.data.length === 0
2563
- || typeof item.mimeType !== "string" || !/^image\/[a-z0-9.+-]+$/i.test(item.mimeType))
2564
- continue;
2565
- const byteLength = Buffer.from(item.data, "base64").byteLength;
2566
- if (byteLength < 1 || byteLength > 20 * 1024 * 1024)
2567
- continue;
2568
- const assetId = `image-${createHash("sha256").update(item.mimeType).update("\0").update(item.data).digest("hex").slice(0, 24)}`;
2569
- this.#transcriptImageAssets.set(assetId, { type: "image", data: item.data, mimeType: item.mimeType });
2570
- references.push({ assetId, mimeType: item.mimeType, byteLength, source });
2594
+ if (references.length >= 16)
2595
+ break;
2596
+ const reference = this.#transcriptImageAssets.reference(item, source);
2597
+ if (reference !== undefined)
2598
+ references.push(reference);
2571
2599
  }
2572
2600
  return references;
2573
2601
  }
@@ -2613,9 +2641,13 @@ export class PiEngineAdapter {
2613
2641
  // revision keeps the rows it already rendered.
2614
2642
  if (existing !== undefined && sameBlockContent(existing, block))
2615
2643
  return;
2644
+ this.#transcriptImageAssets.retain(block);
2645
+ if (existing !== undefined)
2646
+ this.#transcriptImageAssets.release(existing);
2616
2647
  this.#transcript[index] = block;
2617
2648
  }
2618
2649
  else {
2650
+ this.#transcriptImageAssets.retain(block);
2619
2651
  this.#transcriptIndex.set(block.id, this.#transcript.length);
2620
2652
  this.#transcript.push(block);
2621
2653
  }
@@ -2789,9 +2821,15 @@ export class PiEngineAdapter {
2789
2821
  continue;
2790
2822
  if (pending.generation !== this.#sessionGeneration && pending.event.type !== "command-outcome") {
2791
2823
  this.#invalidatedEvents = Math.min(Number.MAX_SAFE_INTEGER, this.#invalidatedEvents + 1);
2824
+ pending.release?.();
2792
2825
  continue;
2793
2826
  }
2794
- this.#deliver(pending.event);
2827
+ try {
2828
+ this.#deliver(pending.event);
2829
+ }
2830
+ finally {
2831
+ pending.release?.();
2832
+ }
2795
2833
  deliveredSinceYield += 1;
2796
2834
  // Concurrency: a microtask chain runs to exhaustion before the loop turns, so a streaming
2797
2835
  // burst would hold typed input, pointer reports, and timed indicators until it
@@ -6,6 +6,8 @@ export declare function replaceableEventKey(event: OwnedUiEvent): string | null;
6
6
  /** Intrusive FIFO + segment index: replacement unlinks in O(1), never changes source ordering. */
7
7
  export declare class PendingEngineDelivery {
8
8
  #private;
9
+ private readonly retain?;
10
+ constructor(retain?: ((event: OwnedUiEvent) => () => void) | undefined);
9
11
  get size(): number;
10
12
  diagnostics(): {
11
13
  pending: number;
@@ -22,5 +24,7 @@ export declare class PendingEngineDelivery {
22
24
  shift(): {
23
25
  event: OwnedUiEvent;
24
26
  generation: number;
27
+ release?: (() => void) | undefined;
25
28
  } | undefined;
29
+ seal(): boolean;
26
30
  }
@@ -25,6 +25,7 @@ export function replaceableEventKey(event) {
25
25
  }
26
26
  /** Intrusive FIFO + segment index: replacement unlinks in O(1), never changes source ordering. */
27
27
  export class PendingEngineDelivery {
28
+ retain;
28
29
  #replaceable = new Map();
29
30
  #head;
30
31
  #tail;
@@ -35,6 +36,9 @@ export class PendingEngineDelivery {
35
36
  #superseded = 0;
36
37
  #peakNodes = 0;
37
38
  #peakBytes = 0;
39
+ constructor(retain) {
40
+ this.retain = retain;
41
+ }
38
42
  get size() { return this.#size; }
39
43
  diagnostics() {
40
44
  return { pending: this.#size, bytes: this.#bytes, protected: this.#protected,
@@ -44,7 +48,7 @@ export class PendingEngineDelivery {
44
48
  push(event, generation, reconcile) {
45
49
  const key = replaceableEventKey(event);
46
50
  if (generation !== this.#generation || key === null) {
47
- if (!this.#seal())
51
+ if (!this.seal())
48
52
  return false;
49
53
  this.#generation = generation;
50
54
  }
@@ -56,11 +60,11 @@ export class PendingEngineDelivery {
56
60
  if (this.#size - (prior === undefined ? 0 : 1) >= MAX_PENDING_EVENTS
57
61
  || this.#bytes - (prior?.bytes ?? 0) + bytes > MAX_PENDING_EVENT_BYTES)
58
62
  return false;
63
+ const node = { key, generation, bytes, ...(marker ? { resolve: reconcile } : { event, release: this.retain?.(event) }) };
59
64
  if (prior !== undefined) {
60
65
  this.#unlink(prior);
61
66
  this.#superseded = Math.min(Number.MAX_SAFE_INTEGER, this.#superseded + 1);
62
67
  }
63
- const node = { key, generation, bytes, ...(marker ? { resolve: reconcile } : { event }) };
64
68
  node.previous = this.#tail;
65
69
  if (this.#tail !== undefined)
66
70
  this.#tail.next = node;
@@ -98,11 +102,13 @@ export class PendingEngineDelivery {
98
102
  if (node === undefined)
99
103
  return undefined;
100
104
  const event = node.event ?? node.resolve();
101
- this.#unlink(node);
102
- return { event, generation: node.generation };
105
+ const release = node.release ?? this.retain?.(event);
106
+ this.#unlink(node, false);
107
+ // Invariant: a consumed snapshot retains its assets through synchronous listener delivery, including reentrant production.
108
+ return { event, generation: node.generation, ...(release === undefined ? {} : { release }) };
103
109
  }
104
110
  // Invariant: a barrier freezes lazy authoritative markers before a later segment can change them.
105
- #seal() {
111
+ seal() {
106
112
  for (const node of this.#replaceable.values()) {
107
113
  if (node.resolve === undefined)
108
114
  continue;
@@ -114,12 +120,15 @@ export class PendingEngineDelivery {
114
120
  node.bytes = bytes;
115
121
  node.event = event;
116
122
  node.resolve = undefined;
123
+ node.release = this.retain?.(event);
117
124
  this.#peakBytes = Math.max(this.#peakBytes, this.#bytes);
118
125
  }
119
126
  this.#replaceable.clear();
120
127
  return true;
121
128
  }
122
- #unlink(node) {
129
+ #unlink(node, release = true) {
130
+ if (release)
131
+ node.release?.();
123
132
  if (node.previous !== undefined)
124
133
  node.previous.next = node.next;
125
134
  else
@@ -1,4 +1,5 @@
1
1
  const MAX_METADATA_BYTES = 64 * 1024;
2
+ const MAX_TEXT_BYTES = 256 * 1024;
2
3
  /** Copy JSON metadata without diagnostic depth/array truncation or traversing result text/images. */
3
4
  function renderingJson(value) {
4
5
  let remaining = MAX_METADATA_BYTES;
@@ -48,27 +49,49 @@ export function toolRenderingInput(options) {
48
49
  }
49
50
  const imageReferences = [];
50
51
  const content = [];
52
+ let contentBytes = 2;
53
+ let excessBoundaries = false;
54
+ const addPart = (part) => {
55
+ if (excessBoundaries)
56
+ return;
57
+ // Performance: these ASCII-only offsets/indexes are budgeted before retaining or encoding an oversized boundary array.
58
+ contentBytes += JSON.stringify(part).length + (content.length > 0 ? 1 : 0);
59
+ if (contentBytes > MAX_METADATA_BYTES)
60
+ excessBoundaries = true;
61
+ else
62
+ content.push(part);
63
+ };
51
64
  const texts = [];
52
65
  let length = 0;
66
+ let textBytes = 0;
53
67
  const source = isRecord(options.result) ? options.result : undefined;
54
68
  const rawContent = source === undefined ? options.result : source.content;
55
69
  const parts = typeof rawContent === "string" ? [{ type: "text", text: rawContent }] : rawContent;
56
70
  if (Array.isArray(parts)) {
57
71
  for (const part of parts) {
58
72
  if (isRecord(part) && part.type === "text" && typeof part.text === "string") {
73
+ const separatorBytes = texts.length > 0 ? 1 : 0;
74
+ // Performance: length rejects huge strings before the bounded UTF-8 count; never serialize accumulated text.
75
+ const partBytes = part.text.length + textBytes + separatorBytes > MAX_TEXT_BYTES
76
+ ? MAX_TEXT_BYTES + 1 : Buffer.byteLength(part.text);
77
+ if (partBytes + textBytes + separatorBytes > MAX_TEXT_BYTES) {
78
+ warnings.add("Tool text unavailable: result exceeds the supported 256 KiB text limit.");
79
+ continue;
80
+ }
81
+ textBytes += partBytes + separatorBytes;
59
82
  if (texts.length > 0)
60
83
  length++;
61
84
  const start = length;
62
85
  texts.push(part.text);
63
86
  length += part.text.length;
64
- content.push({ type: "text", start, end: length });
87
+ addPart({ type: "text", start, end: length });
65
88
  }
66
89
  else if (isRecord(part) && part.type === "image") {
67
90
  const reference = imageReferences.length < 16 ? options.image(part) : undefined;
68
91
  if (reference === undefined)
69
92
  warnings.add("Image unavailable: unsupported data or attachment limit exceeded.");
70
93
  else {
71
- content.push({ type: "image", imageIndex: imageReferences.length });
94
+ addPart({ type: "image", imageIndex: imageReferences.length });
72
95
  imageReferences.push(reference);
73
96
  }
74
97
  }
@@ -93,7 +116,7 @@ export function toolRenderingInput(options) {
93
116
  ...(options.result === undefined ? {} : { result: { content, ...(details === undefined ? {} : { details }) } }),
94
117
  };
95
118
  const warning = () => [...warnings].join(" ");
96
- const fits = () => Buffer.byteLength(JSON.stringify({ payload: options.payload, toolRendering: {
119
+ const fits = () => !excessBoundaries && Buffer.byteLength(JSON.stringify({ payload: options.payload, toolRendering: {
97
120
  ...toolRendering, ...(warnings.size === 0 ? {} : { unavailable: warning() }),
98
121
  } })) <= MAX_METADATA_BYTES;
99
122
  if (!fits()) {
@@ -0,0 +1,11 @@
1
+ import type { OwnedUiEvent, OwnedUiImageAttachment, OwnedUiTranscriptBlock, OwnedUiTranscriptImageReference } from "../../../contracts/owned-ui/index.js";
2
+ export declare class TranscriptImageAssets {
3
+ #private;
4
+ resolve(id: string): OwnedUiImageAttachment | null;
5
+ clear(): void;
6
+ reference(value: unknown, source: OwnedUiTranscriptImageReference["source"]): OwnedUiTranscriptImageReference | undefined;
7
+ retain(block: OwnedUiTranscriptBlock): void;
8
+ release(block: OwnedUiTranscriptBlock): void;
9
+ retainEvent(event: OwnedUiEvent): () => void;
10
+ discardUnowned(): void;
11
+ }
@@ -0,0 +1,86 @@
1
+ import { createHash } from "node:crypto";
2
+ const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
3
+ // Security: current blocks and admitted delivery snapshots own assets independently; superseded state owns none.
4
+ export class TranscriptImageAssets {
5
+ #assets = new Map();
6
+ #byData = new Map();
7
+ #unowned = new Set();
8
+ resolve(id) { return this.#assets.get(id)?.asset ?? null; }
9
+ clear() { this.#assets.clear(); this.#byData.clear(); this.#unowned.clear(); }
10
+ reference(value, source) {
11
+ if (typeof value !== "object" || value === null || !("type" in value) || value.type !== "image"
12
+ || !("data" in value) || typeof value.data !== "string" || value.data.length === 0
13
+ || !("mimeType" in value) || typeof value.mimeType !== "string" || !/^image\/[a-z0-9.+-]+$/i.test(value.mimeType))
14
+ return;
15
+ // Performance: reject oversized encoded data before allocating a decoded buffer or hashing it.
16
+ if (value.data.length > Math.ceil(MAX_IMAGE_BYTES / 3) * 4)
17
+ return;
18
+ // Performance: repeated accumulated results reuse the same retained bytes without decoding and hashing per chunk.
19
+ const cachedId = this.#byData.get(value.mimeType)?.get(value.data);
20
+ if (cachedId !== undefined)
21
+ return { assetId: cachedId, mimeType: value.mimeType, byteLength: this.#assets.get(cachedId).byteLength, source };
22
+ const byteLength = Buffer.from(value.data, "base64").byteLength;
23
+ if (byteLength < 1 || byteLength > MAX_IMAGE_BYTES)
24
+ return;
25
+ const assetId = `image-${createHash("sha256").update(value.mimeType).update("\0").update(value.data).digest("hex").slice(0, 24)}`;
26
+ if (!this.#assets.has(assetId)) {
27
+ this.#assets.set(assetId, { asset: { type: "image", data: value.data, mimeType: value.mimeType }, byteLength, owners: 0 });
28
+ let byData = this.#byData.get(value.mimeType);
29
+ if (byData === undefined)
30
+ this.#byData.set(value.mimeType, byData = new Map());
31
+ byData.set(value.data, assetId);
32
+ this.#unowned.add(assetId);
33
+ }
34
+ return { assetId, mimeType: value.mimeType, byteLength, source };
35
+ }
36
+ retain(block) {
37
+ for (const reference of block.imageReferences ?? []) {
38
+ const entry = this.#assets.get(reference.assetId);
39
+ if (entry === undefined)
40
+ continue;
41
+ entry.owners++;
42
+ this.#unowned.delete(reference.assetId);
43
+ }
44
+ }
45
+ release(block) {
46
+ for (const reference of block.imageReferences ?? []) {
47
+ const entry = this.#assets.get(reference.assetId);
48
+ if (entry !== undefined && --entry.owners === 0)
49
+ this.#delete(reference.assetId);
50
+ }
51
+ }
52
+ retainEvent(event) {
53
+ const blocks = event.type === "transcript-block" ? [event.block] : event.type === "session-view" ? event.view.transcript : [];
54
+ // Concurrency: capture entries, not ids; an obsolete generation's release cannot decrement a replacement asset.
55
+ const entries = blocks.flatMap(block => (block.imageReferences ?? []).map(ref => [ref.assetId, this.#assets.get(ref.assetId)]));
56
+ for (const [id, entry] of entries) {
57
+ if (entry === undefined)
58
+ continue;
59
+ entry.owners++;
60
+ this.#unowned.delete(id);
61
+ }
62
+ return () => {
63
+ for (const [id, entry] of entries) {
64
+ if (entry !== undefined && this.#assets.get(id) === entry && --entry.owners === 0)
65
+ this.#delete(id);
66
+ }
67
+ entries.length = 0;
68
+ };
69
+ }
70
+ discardUnowned() {
71
+ for (const id of this.#unowned)
72
+ this.#delete(id);
73
+ this.#unowned.clear();
74
+ }
75
+ #delete(id) {
76
+ const entry = this.#assets.get(id);
77
+ if (entry === undefined)
78
+ return;
79
+ const byData = this.#byData.get(entry.asset.mimeType);
80
+ byData?.delete(entry.asset.data);
81
+ if (byData?.size === 0)
82
+ this.#byData.delete(entry.asset.mimeType);
83
+ this.#assets.delete(id);
84
+ this.#unowned.delete(id);
85
+ }
86
+ }
@@ -0,0 +1,16 @@
1
+ import type { PiTuiInputDiagnosticsEvent } from "../tui-runtime/index.js";
2
+ import type { ResponseCopyEvent } from "./response-copy-protocol.js";
3
+ import type { PasteEvent } from "./paste-protocol.js";
4
+ /** Opt-in scalar-only snapshots, bounded to 128 rows and one pending asynchronous write. */
5
+ export declare class ClipboardDiagnosticCapture {
6
+ #private;
7
+ private readonly destination;
8
+ private readonly write;
9
+ constructor(destination: string, write?: (file: string, data: string) => Promise<void>);
10
+ copy(event: ResponseCopyEvent): void;
11
+ paste(event: PasteEvent): void;
12
+ runtime(event: PiTuiInputDiagnosticsEvent): void;
13
+ /** Explicit test/evidence boundary; the interactive UI never waits for disk diagnostics. */
14
+ flush(): Promise<void>;
15
+ dispose(): void;
16
+ }
@@ -0,0 +1,65 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ const PHASES = new Set(["capture", "selection-clear", "queued", "preparing", "extracted", "encoded", "submitting", "settled", "cleanup",
3
+ "framing", "admitted", "predecessor", "acquiring", "acquired-text", "acquired-image", "classifying", "path-fallback", "prepared", "inserting",
4
+ "receipt", "semantic-start", "semantic-end", "composition-start", "composition-end", "write-start", "write-end", "heartbeat"]);
5
+ /** Opt-in scalar-only snapshots, bounded to 128 rows and one pending asynchronous write. */
6
+ export class ClipboardDiagnosticCapture {
7
+ destination;
8
+ write;
9
+ #records = [];
10
+ #payloads = new Map();
11
+ #pending;
12
+ #writing;
13
+ #disposed = false;
14
+ #heartbeat;
15
+ constructor(destination, write = (file, data) => writeFile(file, data, { encoding: "utf8", mode: 0o600 })) {
16
+ this.destination = destination;
17
+ this.write = write;
18
+ this.#heartbeat = setInterval(() => this.#record("heartbeat", { phase: "heartbeat", atMs: performance.now() }), 100);
19
+ this.#heartbeat.unref();
20
+ }
21
+ copy(event) { this.#record("copy", event); }
22
+ paste(event) { this.#record("paste", event); }
23
+ runtime(event) {
24
+ this.#record("runtime", { phase: event.phase, atMs: event.atMs, request: event.revision, pending: event.pendingDepth });
25
+ }
26
+ #record(source, event) {
27
+ if (this.#disposed || !PHASES.has(event.phase))
28
+ return;
29
+ const key = `${source}:${event.request ?? 0}`;
30
+ // Concurrency: eight pastes plus active/pending copy and the incoming superseding capture can overlap briefly.
31
+ if (["capture", "admitted"].includes(event.phase) && this.#payloads.size < 11)
32
+ this.#payloads.set(key, scalar(event.sourceUnits) * 2);
33
+ if (this.#payloads.has(key) && event.bytes !== undefined)
34
+ this.#payloads.set(key, Math.max(this.#payloads.get(key), scalar(event.bytes)));
35
+ if (event.phase === "settled" || event.phase === "cleanup")
36
+ this.#payloads.delete(key);
37
+ // Rationale: observed payload/source bytes, not an assertion about opaque native allocator overhead.
38
+ const pendingBytes = scalar([...this.#payloads.values()].reduce((sum, bytes) => sum + bytes, 0));
39
+ // Security: project only declared scalar fields. Never serialize the supplied event object or raw errors.
40
+ this.#records.push({ source, phase: event.phase, atMs: scalar(event.atMs), request: event.request !== undefined && event.request < 0 ? -scalar(-event.request) : scalar(event.request), pending: scalar(event.pending), bytes: scalar(event.bytes), elapsedMs: scalar(event.elapsedMs),
41
+ sourceUnits: scalar(event.sourceUnits), pendingBytes,
42
+ transport: ["native", "terminal", "provided", "injected"].includes(event.transport ?? "") ? event.transport : "none",
43
+ outcome: ["ready", "failed", "canceled", "delivered", "submitted-unverified", "timed-out", "superseded"].includes(event.outcome ?? "") ? event.outcome : "none" });
44
+ if (this.#records.length > 128)
45
+ this.#records.shift();
46
+ this.#pending = JSON.stringify({ schema: "clipboard-diagnostics-v1", records: this.#records });
47
+ this.#pump();
48
+ }
49
+ #pump() {
50
+ if (this.#disposed || this.#writing || this.#pending === undefined)
51
+ return;
52
+ const snapshot = this.#pending;
53
+ this.#pending = undefined;
54
+ this.#writing = Promise.resolve().then(() => this.write(this.destination, snapshot)).catch(() => {
55
+ // Security: diagnostics failure must not enter the normal terminal or block clipboard work.
56
+ }).finally(() => { this.#writing = undefined; this.#pump(); });
57
+ }
58
+ /** Explicit test/evidence boundary; the interactive UI never waits for disk diagnostics. */
59
+ async flush() { while (this.#writing)
60
+ await this.#writing; }
61
+ dispose() { this.#disposed = true; clearInterval(this.#heartbeat); this.#pending = undefined; this.#records = []; this.#payloads.clear(); }
62
+ }
63
+ function scalar(value) {
64
+ return value !== undefined && Number.isFinite(value) ? Math.max(0, Math.min(Number.MAX_SAFE_INTEGER, value)) : 0;
65
+ }
@@ -0,0 +1,9 @@
1
+ /** Aggregate explicit URL-chip metadata allowance for one owned editor decoration pass. */
2
+ export declare const EDITOR_HYPERLINK_METADATA_BYTES: number;
3
+ /** Accounts complete OSC 8 controls before construction, without scanning oversized backing URLs. */
4
+ export declare class EditorHyperlinkBudget {
5
+ #private;
6
+ reset(): void;
7
+ takeCleanup(): boolean;
8
+ take(target: string): boolean;
9
+ }
@@ -0,0 +1,23 @@
1
+ /** Aggregate explicit URL-chip metadata allowance for one owned editor decoration pass. */
2
+ export const EDITOR_HYPERLINK_METADATA_BYTES = 64 * 1024;
3
+ const CLOSE_BYTES = 7;
4
+ const PAIR_BYTES = CLOSE_BYTES * 2;
5
+ /** Accounts complete OSC 8 controls before construction, without scanning oversized backing URLs. */
6
+ export class EditorHyperlinkBudget {
7
+ #remaining = EDITOR_HYPERLINK_METADATA_BYTES;
8
+ reset() { this.#remaining = EDITOR_HYPERLINK_METADATA_BYTES; }
9
+ takeCleanup() { return this.#reserve(CLOSE_BYTES); }
10
+ take(target) {
11
+ // Performance: UTF-16 length is a lower bound on UTF-8 bytes, including replacement surrogates.
12
+ // Reject large backing values before byte counting, flattening, interpolation, or signatures.
13
+ if (target.length > this.#remaining - PAIR_BYTES)
14
+ return false;
15
+ return this.#reserve(PAIR_BYTES + Buffer.byteLength(target, "utf8"));
16
+ }
17
+ #reserve(bytes) {
18
+ if (bytes > this.#remaining)
19
+ return false;
20
+ this.#remaining -= bytes;
21
+ return true;
22
+ }
23
+ }
@@ -1,3 +1,4 @@
1
+ export * from "./clipboard-diagnostics.js";
1
2
  export * from "./prompt-suggestion-controller.js";
2
3
  export * from "./session-shell.js";
3
4
  export * from "./stream-presentation-coalescer.js";
@@ -1,3 +1,4 @@
1
+ export * from "./clipboard-diagnostics.js";
1
2
  export * from "./prompt-suggestion-controller.js";
2
3
  export * from "./session-shell.js";
3
4
  export * from "./stream-presentation-coalescer.js";
@@ -0,0 +1,10 @@
1
+ import type { PiShellClipboardContent } from "../components/index.js";
2
+ import { type PreparedPaste, type PasteEvent } from "./paste-protocol.js";
3
+ export interface PasteExecutorJob {
4
+ readonly result: Promise<PreparedPaste>;
5
+ readonly stopped: Promise<void>;
6
+ cancel(): void;
7
+ }
8
+ export type PastePhase = (phase: PasteEvent["phase"], bytes?: number) => void;
9
+ /** Streams bounded fragments through a killable child; native/codec/filesystem work never runs on the UI thread. */
10
+ export declare function startPasteExecutor(content: PiShellClipboardContent | undefined, signal: AbortSignal, phase?: PastePhase, helper?: URL): PasteExecutorJob;