@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
@@ -0,0 +1,189 @@
1
+ import { MAX_COPY_SOURCE_UNITS } from "../../../ui/components/index.js";
2
+ import { COPY_CLEANUP_MS, COPY_DEADLINE_MS } from "./response-copy-protocol.js";
3
+ /** Owns one delivery and one newest pending copy, including expiry and side-effect fencing. */
4
+ export class ResponseCopyCoordinator {
5
+ options;
6
+ #sequence = 0;
7
+ #epoch = 0;
8
+ #active;
9
+ #pending;
10
+ #latest = Promise.resolve({ outcome: "delivered" });
11
+ #latestPending = false;
12
+ #disposed = false;
13
+ #quarantined = false;
14
+ constructor(options) {
15
+ this.options = options;
16
+ }
17
+ get busy() { return this.#active !== undefined || this.#pending !== undefined; }
18
+ get latest() { return this.#latest; }
19
+ /** Exact prompt copy/cut shares the write lifetime without transcript text transformations. */
20
+ submitText(text) {
21
+ return this.submit(Object.freeze({
22
+ literal: true, revision: 0, sourceUnits: text.length,
23
+ selection: Object.freeze({ start: Object.freeze({ line: 0, column: 0 }), end: Object.freeze({ line: 0, column: Number.MAX_SAFE_INTEGER }) }),
24
+ rows: Object.freeze(text.length > MAX_COPY_SOURCE_UNITS ? [] : [Object.freeze({ text })]),
25
+ ...(text.length > MAX_COPY_SOURCE_UNITS ? { rejected: "size" } : {}),
26
+ }));
27
+ }
28
+ /** Admission is synchronous, but preparation/startup never runs in the input handler's stack. */
29
+ submit(snapshot, before = Promise.resolve()) {
30
+ const id = ++this.#sequence;
31
+ const admitted = this.#now() - (snapshot.captureMs ?? 0);
32
+ let resolve;
33
+ const result = new Promise(done => { resolve = done; });
34
+ this.#latest = result;
35
+ this.#latestPending = true;
36
+ const request = {
37
+ id, epoch: this.#epoch, admitted, sourceUnits: snapshot.sourceUnits, snapshot, before, resolve, done: false, submitting: false,
38
+ timer: setTimeout(() => this.#cancel(request, { outcome: "timed-out" }), Math.max(0, COPY_DEADLINE_MS - (snapshot.captureMs ?? 0))),
39
+ };
40
+ request.timer.unref?.();
41
+ this.#trace(request, "capture", { elapsedMs: snapshot.captureMs ?? 0 });
42
+ if (snapshot.literal !== true)
43
+ this.#trace(request, "selection-clear");
44
+ if (this.#disposed || this.#quarantined || snapshot.rejected) {
45
+ this.#settle(request, { outcome: this.#disposed ? "canceled" : "failed", failure: snapshot.rejected ? "size" : "unsafe" });
46
+ return result;
47
+ }
48
+ if (this.#pending)
49
+ this.#cancel(this.#pending, { outcome: "superseded" });
50
+ this.#pending = request;
51
+ if (this.#active && !this.#active.submitting)
52
+ this.#cancel(this.#active, { outcome: "superseded" });
53
+ this.#trace(request, "queued");
54
+ this.#drain();
55
+ return result;
56
+ }
57
+ /** Capture the current copy barrier, without making later copies retroactively delay this paste. */
58
+ canPaste(signal) { return this.capturePasteBarrier()(signal); }
59
+ /** Only a pending write is a prerequisite; a completed failure cannot poison independent future reads. */
60
+ capturePasteBarrier() {
61
+ const latest = this.#latestPending ? this.#latest : undefined;
62
+ return async (signal) => {
63
+ if (signal.aborted)
64
+ return false;
65
+ if (latest === undefined)
66
+ return true;
67
+ return new Promise(resolve => {
68
+ const abort = () => resolve(false);
69
+ signal.addEventListener("abort", abort, { once: true });
70
+ void latest.then(result => {
71
+ signal.removeEventListener("abort", abort);
72
+ resolve(!signal.aborted && (result.outcome === "delivered" || result.outcome === "submitted-unverified"));
73
+ });
74
+ });
75
+ };
76
+ }
77
+ /** Session replacement fences the old executor rather than discarding its live side effects. */
78
+ reset() {
79
+ this.#epoch++;
80
+ if (this.#pending)
81
+ this.#cancel(this.#pending, { outcome: "canceled" });
82
+ if (this.#active)
83
+ this.#cancel(this.#active, { outcome: "canceled" });
84
+ this.#latestPending = false;
85
+ this.#latest = Promise.resolve({ outcome: "delivered" });
86
+ }
87
+ dispose() { this.#disposed = true; this.reset(); }
88
+ #drain() {
89
+ if (this.#active || this.#disposed || this.#quarantined)
90
+ return;
91
+ const request = this.#pending;
92
+ this.#pending = undefined;
93
+ if (!request || request.done)
94
+ return;
95
+ this.#active = request;
96
+ // Concurrency: render and input get an event-loop turn before child startup or source transfer.
97
+ setImmediate(() => {
98
+ if (request.done) {
99
+ this.#release(request);
100
+ return;
101
+ }
102
+ let canceled = false;
103
+ let executor;
104
+ const run = request.before.then(async () => {
105
+ if (canceled || request.done || request.snapshot === undefined)
106
+ return { outcome: "canceled" };
107
+ this.#trace(request, "preparing");
108
+ executor = this.options.execute(request.snapshot, (phase, bytes, transport) => {
109
+ if (request.done)
110
+ return;
111
+ if (phase === "submitting")
112
+ request.submitting = true;
113
+ this.#trace(request, phase, { bytes, transport });
114
+ });
115
+ return executor.result;
116
+ }).catch(() => ({ outcome: "failed", failure: "transport" }));
117
+ // Concurrency: a canceled gate has not started any side effect. Do not wait for an unrelated prior prompt indefinitely.
118
+ let cancelGate;
119
+ const gateStopped = new Promise(resolve => { cancelGate = resolve; });
120
+ const stopped = Promise.race([
121
+ run.then(async () => { await executor?.stopped; }), gateStopped,
122
+ ]);
123
+ request.job = { result: run, stopped, cancel: () => {
124
+ canceled = true;
125
+ if (executor)
126
+ executor.cancel();
127
+ else
128
+ cancelGate();
129
+ } };
130
+ void run.then(result => this.#settle(request, result));
131
+ void stopped.then(() => this.#release(request));
132
+ });
133
+ }
134
+ #cancel(request, result) {
135
+ if (request.done)
136
+ return;
137
+ this.#settle(request, result);
138
+ request.job?.cancel();
139
+ if (this.#pending === request)
140
+ this.#pending = undefined;
141
+ if (this.#active === request && request.job) {
142
+ const cleanup = setTimeout(() => {
143
+ if (this.#active !== request)
144
+ return;
145
+ this.#quarantined = true;
146
+ this.#trace(request, "cleanup");
147
+ if (this.#pending)
148
+ this.#cancel(this.#pending, { outcome: "failed", failure: "unsafe" });
149
+ }, COPY_CLEANUP_MS);
150
+ cleanup.unref?.();
151
+ void request.job.stopped.then(() => clearTimeout(cleanup));
152
+ }
153
+ }
154
+ #settle(request, result) {
155
+ if (request.done)
156
+ return;
157
+ request.done = true;
158
+ if (request.id === this.#sequence)
159
+ this.#latestPending = false;
160
+ request.snapshot = undefined;
161
+ clearTimeout(request.timer);
162
+ this.#trace(request, "settled", { outcome: result.outcome });
163
+ request.resolve(result);
164
+ if (!this.#disposed && request.epoch === this.#epoch && request.id === this.#sequence
165
+ && (result.outcome === "failed" || result.outcome === "timed-out")) {
166
+ this.options.onFailure?.(result);
167
+ }
168
+ }
169
+ #release(request) {
170
+ if (this.#active !== request)
171
+ return;
172
+ this.#active = undefined;
173
+ this.#quarantined = false;
174
+ this.#trace(request, "cleanup");
175
+ this.#drain();
176
+ }
177
+ #now() { return this.options.now?.() ?? performance.now(); }
178
+ #trace(request, phase, extra = {}) {
179
+ if (this.options.onEvent === undefined)
180
+ return;
181
+ const atMs = this.#now();
182
+ // Security: observations contain only bounded scalar metadata, never copied source or encoded content.
183
+ try {
184
+ this.options.onEvent({ request: request.id, phase, atMs, elapsedMs: atMs - request.admitted,
185
+ sourceUnits: request.sourceUnits, pending: Number(this.#active !== undefined) + Number(this.#pending !== undefined), ...extra });
186
+ }
187
+ catch { /* Invariant: diagnostics must not alter copy semantics. */ }
188
+ }
189
+ }
@@ -0,0 +1,122 @@
1
+ import { MAX_COPY_ROWS, MAX_COPY_SOURCE_UNITS, selectionCopyRowText } from "../../../ui/components/index.js";
2
+ import { COPY_CHUNK_UNITS, MAX_COPY_BYTES, MAX_COPY_CONTROL_BYTES } from "./response-copy-protocol.js";
3
+ // Security: this isolated executor never inherits the UI terminal and never logs clipboard contents.
4
+ let header;
5
+ let fragments = [];
6
+ let parts = [];
7
+ let rowIndex = 0, sourceUnits = 0, bytes = 0;
8
+ let finished = false;
9
+ let output;
10
+ let outputOffset = 0;
11
+ function nextOutput() {
12
+ if (output === undefined)
13
+ throw new Error("protocol");
14
+ if (outputOffset === output.length) {
15
+ output = undefined;
16
+ finish({ outcome: "delivered" });
17
+ return;
18
+ }
19
+ let end = Math.min(output.length, outputOffset + COPY_CHUNK_UNITS);
20
+ if (end < output.length && /[\uD800-\uDBFF]/u.test(output[end - 1]) && /[\uDC00-\uDFFF]/u.test(output[end]))
21
+ end--;
22
+ const text = output.slice(outputOffset, end);
23
+ outputOffset = end;
24
+ send({ kind: "data", text });
25
+ }
26
+ function send(message) { process.send?.(message); }
27
+ function finish(result, control) {
28
+ if (finished)
29
+ return;
30
+ finished = true;
31
+ fragments = [];
32
+ parts = [];
33
+ process.send?.({ kind: "result", result, ...(control === undefined ? {} : { control }) }, () => process.exit(0));
34
+ }
35
+ async function receive(message) {
36
+ if (finished)
37
+ return;
38
+ if (message?.kind === "next" && header?.mode === "prepare") {
39
+ nextOutput();
40
+ return;
41
+ }
42
+ if (message?.kind === "begin" && header === undefined) {
43
+ if (!Number.isSafeInteger(message.rows) || message.rows < 1 || message.rows > MAX_COPY_ROWS
44
+ || !["native", "terminal", "prepare"].includes(message.mode))
45
+ throw new Error("protocol");
46
+ header = message;
47
+ }
48
+ else if (message?.kind === "row" && header !== undefined) {
49
+ if (typeof message.text !== "string" || message.text.length > COPY_CHUNK_UNITS || rowIndex >= header.rows)
50
+ throw new Error("protocol");
51
+ sourceUnits += message.text.length;
52
+ if (sourceUnits > MAX_COPY_SOURCE_UNITS) {
53
+ finish({ outcome: "failed", failure: "size" });
54
+ return;
55
+ }
56
+ fragments.push(message.text);
57
+ if (message.end) {
58
+ const text = selectionCopyRowText(header, { text: fragments.join(""), ...(message.prompt === undefined ? {} : { prompt: message.prompt }) }, rowIndex);
59
+ fragments = [];
60
+ bytes += Buffer.byteLength(text, "utf8") + (rowIndex === 0 ? 0 : 1);
61
+ if (bytes > MAX_COPY_BYTES) {
62
+ finish({ outcome: "failed", failure: "size" });
63
+ return;
64
+ }
65
+ parts.push(text);
66
+ rowIndex++;
67
+ }
68
+ }
69
+ else if (message?.kind === "finish" && header !== undefined && rowIndex === header.rows && fragments.length === 0) {
70
+ send({ kind: "phase", phase: "extracted", bytes });
71
+ const text = parts.join("\n");
72
+ parts = [];
73
+ if (text.length === 0) {
74
+ finish({ outcome: "failed", failure: "size" });
75
+ return;
76
+ }
77
+ if (header.mode === "prepare") {
78
+ // Protocol: this success acknowledges prepared IPC only; the owner must still await its injected writer.
79
+ output = text;
80
+ nextOutput();
81
+ return;
82
+ }
83
+ if (header.mode === "terminal") {
84
+ const limit = Math.min(MAX_COPY_CONTROL_BYTES, header.controlLimit);
85
+ if (!Number.isSafeInteger(limit) || limit < 8 || 8 + 4 * Math.ceil(bytes / 3) > limit) {
86
+ finish({ outcome: "failed", failure: "size" });
87
+ return;
88
+ }
89
+ const control = `\u001b]52;c;${Buffer.from(text, "utf8").toString("base64")}\u0007`;
90
+ send({ kind: "phase", phase: "encoded", bytes });
91
+ finish({ outcome: "submitted-unverified" }, control);
92
+ return;
93
+ }
94
+ // Platform: even an async native binding may block internally; the parent can terminate this process.
95
+ let native;
96
+ try {
97
+ native = await import("@mariozechner/clipboard");
98
+ }
99
+ catch {
100
+ finish({ outcome: "failed", failure: "unavailable" });
101
+ return;
102
+ }
103
+ send({ kind: "phase", phase: "submitting", bytes });
104
+ try {
105
+ await native.setText(text);
106
+ finish({ outcome: "delivered" });
107
+ }
108
+ catch {
109
+ finish({ outcome: "failed", failure: "denied" });
110
+ }
111
+ return;
112
+ }
113
+ else
114
+ throw new Error("protocol");
115
+ send({ kind: "ready" });
116
+ }
117
+ process.on("message", message => {
118
+ void receive(message).catch(() => finish({ outcome: "failed", failure: "transport" }));
119
+ });
120
+ // Concurrency: a vanished owner must never leave a clipboard worker alive.
121
+ process.on("disconnect", () => process.exit(0));
122
+ send({ kind: "ready" });
@@ -0,0 +1,56 @@
1
+ import type { OrderedTextSelection, SelectionCopyPrompt } from "../../../ui/components/index.js";
2
+ export declare const COPY_DEADLINE_MS = 5000;
3
+ export declare const COPY_CLEANUP_MS = 250;
4
+ export declare const MAX_COPY_BYTES: number;
5
+ export declare const MAX_COPY_CONTROL_BYTES: number;
6
+ export declare const COPY_CHUNK_UNITS = 16384;
7
+ export type CopyOutcome = "delivered" | "submitted-unverified" | "failed" | "timed-out" | "superseded" | "canceled";
8
+ export type CopyFailure = "size" | "unavailable" | "denied" | "transport" | "unsafe";
9
+ export interface CopyResult {
10
+ readonly outcome: CopyOutcome;
11
+ readonly failure?: CopyFailure;
12
+ }
13
+ /** Payload-free observations; callers must not attach transcript/clipboard content. */
14
+ export interface ResponseCopyEvent {
15
+ readonly request: number;
16
+ readonly phase: "capture" | "selection-clear" | "queued" | "preparing" | "extracted" | "encoded" | "submitting" | "settled" | "cleanup";
17
+ readonly atMs: number;
18
+ readonly elapsedMs: number;
19
+ readonly pending: number;
20
+ readonly sourceUnits: number;
21
+ readonly bytes?: number;
22
+ readonly transport?: "native" | "terminal" | "injected";
23
+ readonly outcome?: CopyOutcome;
24
+ }
25
+ /** One request per helper lifetime. Each source fragment is acknowledged before the next is sent. */
26
+ export type CopyHelperInput = {
27
+ readonly kind: "begin";
28
+ readonly selection: OrderedTextSelection;
29
+ readonly rows: number;
30
+ readonly mode: "native" | "terminal" | "prepare";
31
+ readonly literal?: boolean;
32
+ readonly controlLimit: number;
33
+ } | {
34
+ readonly kind: "row";
35
+ readonly text: string;
36
+ readonly end: boolean;
37
+ readonly prompt?: Exclude<SelectionCopyPrompt, undefined>;
38
+ } | {
39
+ readonly kind: "finish";
40
+ } | {
41
+ readonly kind: "next";
42
+ };
43
+ export type CopyHelperOutput = {
44
+ readonly kind: "ready";
45
+ } | {
46
+ readonly kind: "data";
47
+ readonly text: string;
48
+ } | {
49
+ readonly kind: "phase";
50
+ readonly phase: "extracted" | "encoded" | "submitting";
51
+ readonly bytes: number;
52
+ } | {
53
+ readonly kind: "result";
54
+ readonly result: CopyResult;
55
+ readonly control?: string;
56
+ };
@@ -0,0 +1,5 @@
1
+ export const COPY_DEADLINE_MS = 5_000;
2
+ export const COPY_CLEANUP_MS = 250;
3
+ export const MAX_COPY_BYTES = 16 * 1024 * 1024;
4
+ export const MAX_COPY_CONTROL_BYTES = 64 * 1024;
5
+ export const COPY_CHUNK_UNITS = 16_384;
@@ -0,0 +1,31 @@
1
+ import type { SelectionCopySnapshot } from "../../../ui/components/index.js";
2
+ import { type CopyResult } from "./response-copy-protocol.js";
3
+ export interface ResponseCopyJob {
4
+ readonly result: Promise<CopyResult>;
5
+ /** Resolves only after the executor can no longer produce clipboard side effects. */
6
+ readonly stopped: Promise<void>;
7
+ cancel(): void;
8
+ }
9
+ export type CopyPhaseObserver = (phase: "extracted" | "encoded" | "submitting", bytes: number, transport: "native" | "terminal" | "injected") => void;
10
+ export type ResponseCopyExecutor = (snapshot: SelectionCopySnapshot, phase: CopyPhaseObserver) => ResponseCopyJob;
11
+ export interface ResponseCopyTerminal {
12
+ readonly maxBytes?: number;
13
+ /** A supported non-blocking submission boundary, not clipboard acknowledgment. */
14
+ submit(control: string, signal: AbortSignal): Promise<void>;
15
+ }
16
+ /** Never substitute a remote host's native clipboard for the terminal user's clipboard. */
17
+ export declare function responseCopyDestination(env: NodeJS.ProcessEnv): "native" | "terminal";
18
+ /** Node documents synchronous POSIX TTY and Windows pipe writes; do not put clipboard traffic there. */
19
+ export declare function hasAsyncClipboardOutput(platform?: NodeJS.Platform, stdout?: {
20
+ readonly isTTY?: boolean | undefined;
21
+ readonly fd?: number | undefined;
22
+ }): boolean;
23
+ /** Isolates preparation and native clipboard access; only the owner may submit terminal controls. */
24
+ export declare function createResponseCopyExecutor(options?: {
25
+ readonly terminal?: ResponseCopyTerminal;
26
+ readonly destination?: "native" | "terminal";
27
+ /** Explicit host seam: must be non-blocking and honor cancellation, or its unresolved lifetime is quarantined. */
28
+ readonly writeText?: (text: string, signal: AbortSignal) => Promise<void>;
29
+ /** Package/fault-test seam: the production helper is always an emitted sibling. */
30
+ readonly helper?: URL;
31
+ }): ResponseCopyExecutor;
@@ -0,0 +1,187 @@
1
+ import { fork } from "node:child_process";
2
+ import { fstatSync } from "node:fs";
3
+ import { COPY_CHUNK_UNITS, COPY_CLEANUP_MS, MAX_COPY_BYTES, MAX_COPY_CONTROL_BYTES } from "./response-copy-protocol.js";
4
+ /** Never substitute a remote host's native clipboard for the terminal user's clipboard. */
5
+ export function responseCopyDestination(env) {
6
+ return env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY || env.TMUX || env.STY || env.WSL_DISTRO_NAME
7
+ ? "terminal" : "native";
8
+ }
9
+ /** Node documents synchronous POSIX TTY and Windows pipe writes; do not put clipboard traffic there. */
10
+ export function hasAsyncClipboardOutput(platform = process.platform, stdout = process.stdout) {
11
+ if (stdout.isTTY)
12
+ return platform === "win32";
13
+ if (platform === "win32")
14
+ return false;
15
+ try {
16
+ const stat = fstatSync(stdout.fd ?? -1);
17
+ return stat.isFIFO() || stat.isSocket();
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ /** Isolates preparation and native clipboard access; only the owner may submit terminal controls. */
24
+ export function createResponseCopyExecutor(options = {}) {
25
+ return (snapshot, phase) => {
26
+ const controller = new AbortController();
27
+ let active;
28
+ const run = async () => {
29
+ const destination = options.writeText === undefined ? options.destination ?? responseCopyDestination(process.env) : "prepare";
30
+ const execute = async (mode) => {
31
+ if (controller.signal.aborted)
32
+ return { kind: "result", result: { outcome: "canceled" } };
33
+ if (mode === "terminal" && options.terminal === undefined) {
34
+ return { kind: "result", result: { outcome: "failed", failure: "unsafe" } };
35
+ }
36
+ active = startHelper(snapshot, mode, options.terminal?.maxBytes ?? MAX_COPY_CONTROL_BYTES, phase, options.helper);
37
+ if (controller.signal.aborted)
38
+ active.cancel();
39
+ return active.result;
40
+ };
41
+ let outcome = await execute(destination);
42
+ // Concurrency: helper results settle only after exit; fallback cannot race an older native write.
43
+ if (!controller.signal.aborted && destination === "native" && outcome.result.failure === "unavailable" && options.terminal !== undefined) {
44
+ outcome = await execute("terminal");
45
+ }
46
+ if (controller.signal.aborted)
47
+ return { outcome: "canceled" };
48
+ if (destination === "prepare" && outcome.result.outcome === "delivered") {
49
+ if (outcome.text === undefined)
50
+ return { outcome: "failed", failure: "transport" };
51
+ phase("submitting", outcome.bytes ?? 0, "injected");
52
+ await options.writeText(outcome.text, controller.signal);
53
+ }
54
+ if (outcome.result.outcome === "submitted-unverified") {
55
+ const control = "control" in outcome ? outcome.control : undefined;
56
+ if (typeof control !== "string" || !/^\u001b\]52;c;[A-Za-z0-9+/]*={0,2}\u0007$/u.test(control)
57
+ || Buffer.byteLength(control) > Math.min(MAX_COPY_CONTROL_BYTES, options.terminal === undefined ? 0 : options.terminal.maxBytes ?? MAX_COPY_CONTROL_BYTES)) {
58
+ return { outcome: "failed", failure: "transport" };
59
+ }
60
+ phase("submitting", control.length, "terminal");
61
+ await options.terminal.submit(control, controller.signal);
62
+ }
63
+ return controller.signal.aborted ? { outcome: "canceled" } : outcome.result;
64
+ };
65
+ const result = run().catch(() => ({ outcome: controller.signal.aborted ? "canceled" : "failed", failure: "transport" }));
66
+ return { result, stopped: result.then(() => { }), cancel: () => { controller.abort(); active?.cancel(); } };
67
+ };
68
+ }
69
+ function* sourceMessages(snapshot, mode, controlLimit) {
70
+ yield { kind: "begin", selection: snapshot.selection, rows: snapshot.rows.length, mode, controlLimit,
71
+ ...(snapshot.literal === undefined ? {} : { literal: snapshot.literal }) };
72
+ for (const row of snapshot.rows) {
73
+ for (let offset = 0; offset < Math.max(1, row.text.length); offset += COPY_CHUNK_UNITS) {
74
+ yield { kind: "row", text: row.text.slice(offset, offset + COPY_CHUNK_UNITS), end: offset + COPY_CHUNK_UNITS >= row.text.length,
75
+ ...(row.prompt === undefined ? {} : { prompt: row.prompt }) };
76
+ }
77
+ }
78
+ yield { kind: "finish" };
79
+ }
80
+ function startHelper(snapshot, mode, controlLimit, phase, helper) {
81
+ const source = new URL(import.meta.url).pathname.endsWith(".ts");
82
+ const entry = helper ?? new URL(source ? "./response-copy-helper.ts" : "./response-copy-helper.js", import.meta.url);
83
+ // Security: bounded IPC only, no clipboard payload in argv/temp files, and no inherited terminal descriptors.
84
+ const forkOptions = {
85
+ execArgv: entry.pathname.endsWith(".ts") ? ["--import", "tsx"] : [],
86
+ stdio: ["ignore", "ignore", "ignore", "ipc"], windowsHide: true,
87
+ serialization: "json",
88
+ };
89
+ const child = fork(entry, [], forkOptions);
90
+ let iterator = sourceMessages(snapshot, mode, controlLimit);
91
+ let outcome;
92
+ let stopping = false;
93
+ let prepared = [], preparedBytes = 0, expectedBytes;
94
+ const observedPhases = new Set();
95
+ let cleanup;
96
+ const cancel = () => {
97
+ if (stopping)
98
+ return;
99
+ stopping = true;
100
+ prepared = [];
101
+ iterator = undefined;
102
+ child.kill("SIGKILL");
103
+ // Concurrency: a kernel-stuck child is quarantined by the coordinator, not allowed to pin UI shutdown.
104
+ cleanup = setTimeout(() => { child.unref(); child.channel?.unref(); }, COPY_CLEANUP_MS);
105
+ cleanup.unref();
106
+ };
107
+ const result = new Promise(resolve => {
108
+ let settled = false;
109
+ const settle = () => {
110
+ if (settled)
111
+ return;
112
+ settled = true;
113
+ const value = outcome ?? { kind: "result", result: { outcome: "failed", failure: "transport" } };
114
+ if (mode === "prepare" && value.result.outcome === "delivered") {
115
+ resolve(expectedBytes === preparedBytes ? { ...value, text: prepared.join(""), bytes: preparedBytes }
116
+ : { kind: "result", result: { outcome: "failed", failure: "transport" } });
117
+ }
118
+ else
119
+ resolve(value);
120
+ prepared = [];
121
+ };
122
+ child.on("message", value => {
123
+ const message = value;
124
+ if (stopping)
125
+ return;
126
+ if (message?.kind === "ready") {
127
+ const next = iterator?.next();
128
+ if (next === undefined || next.done) {
129
+ cancel();
130
+ return;
131
+ }
132
+ child.send(next.value, error => { if (error)
133
+ cancel(); });
134
+ }
135
+ else if (message?.kind === "data") {
136
+ if (mode !== "prepare" || expectedBytes === undefined || typeof message.text !== "string" || message.text.length > COPY_CHUNK_UNITS) {
137
+ cancel();
138
+ return;
139
+ }
140
+ preparedBytes += Buffer.byteLength(message.text);
141
+ if (preparedBytes > MAX_COPY_BYTES) {
142
+ cancel();
143
+ return;
144
+ }
145
+ prepared.push(message.text);
146
+ child.send({ kind: "next" }, error => { if (error)
147
+ cancel(); });
148
+ }
149
+ else if (message?.kind === "phase" && ["extracted", "encoded", "submitting"].includes(message.phase)
150
+ && Number.isSafeInteger(message.bytes) && message.bytes >= 0) {
151
+ if (observedPhases.has(message.phase)) {
152
+ cancel();
153
+ return;
154
+ }
155
+ observedPhases.add(message.phase);
156
+ if (message.phase === "extracted")
157
+ expectedBytes = message.bytes;
158
+ phase(message.phase, message.bytes, mode === "prepare" ? "injected" : mode);
159
+ }
160
+ else if (message?.kind === "result" && ["delivered", "submitted-unverified", "failed"].includes(message.result?.outcome)) {
161
+ outcome = message;
162
+ iterator = undefined;
163
+ // Concurrency: a result is not an exit fence. Bound a malformed helper that sends a result but never exits.
164
+ cleanup = setTimeout(cancel, COPY_CLEANUP_MS);
165
+ cleanup.unref();
166
+ }
167
+ else
168
+ cancel();
169
+ });
170
+ child.once("error", cancel);
171
+ child.once("exit", () => {
172
+ iterator = undefined;
173
+ if (cleanup !== undefined)
174
+ clearTimeout(cleanup);
175
+ child.removeAllListeners("message");
176
+ settle();
177
+ });
178
+ // Platform: spawn failure has no exit event, but has a close event and no live executor to fence.
179
+ child.once("close", () => {
180
+ iterator = undefined;
181
+ if (cleanup !== undefined)
182
+ clearTimeout(cleanup);
183
+ settle();
184
+ });
185
+ });
186
+ return { result, cancel };
187
+ }
@@ -6,6 +6,10 @@ import { type PiEngineAdapter, type PiWorkflowMessage, type PiWorkflowResult } f
6
6
  import { type PiShellClipboardContent, type PiShellComponentPort, type PiShellEditorPort, type PiShellExtensionRendererResolver, type PiShellHeaderOptions, type PiShellHeaderPort, type PiShellImageAssetResolver, type PiShellLoadedResourcesPort, type PiShellResourceEntry, type PiShellTranscriptComponentPort } from "../components/index.js";
7
7
  import { type PiTuiComponentPort, type PiTuiInputSurfaceKind, type PiTuiInputCoordinationScheduler, type PiTuiInputDiagnosticsEvent, type PiTuiLayoutNode, type PiTuiTerminalPort } from "../tui-runtime/index.js";
8
8
  import { type PreparedPrompt } from "./prompt-chips.js";
9
+ import { type SessionViewportInputResult } from "./session-viewport-controller.js";
10
+ import type { ResponseCopyExecutor } from "./response-copy-transport.js";
11
+ import type { ResponseCopyEvent } from "./response-copy-protocol.js";
12
+ import type { PasteEvent, PasteSource } from "./paste-protocol.js";
9
13
  import type { StreamPresentationScheduler } from "./stream-presentation-coalescer.js";
10
14
  export type OwnedUiBackendPort = PiEngineAdapter;
11
15
  export type OwnedUiTerminalPort = PiTuiTerminalPort;
@@ -16,7 +20,7 @@ export interface OwnedUiClipboardPort {
16
20
  readonly data: string;
17
21
  readonly mimeType: string;
18
22
  } | null>;
19
- writeText?(text: string): Promise<void>;
23
+ writeText?(text: string, signal?: AbortSignal): Promise<void>;
20
24
  }
21
25
  export interface OwnedUiSessionShellOptions {
22
26
  readonly promptHistory?: {
@@ -43,6 +47,13 @@ export interface OwnedUiSessionShellOptions {
43
47
  readonly viewportSettings?: OwnedUiViewportSettingsPort;
44
48
  /** Optional platform seam; production uses A1's system clipboard adapter. */
45
49
  readonly clipboard?: OwnedUiClipboardPort;
50
+ /** Owned response-copy transport and payload-free diagnostic seams. Comparison profiles ignore them. */
51
+ readonly responseCopy?: {
52
+ readonly execute?: ResponseCopyExecutor;
53
+ readonly onEvent?: (event: ResponseCopyEvent) => void;
54
+ };
55
+ /** Bounded, payload-free clipboard acquisition/preparation observations. */
56
+ readonly pasteDiagnostics?: (event: PasteEvent) => void;
46
57
  /** Deterministic scheduling seam for presentation-cadence tests. */
47
58
  readonly streamPresentation?: {
48
59
  readonly intervalMs?: number;
@@ -97,6 +108,8 @@ export declare class OwnedUiSessionShellRoot implements PiTuiComponentPort {
97
108
  readonly onInputSurfaceChanged?: () => void;
98
109
  readonly onCopyText?: (text: string) => void;
99
110
  readonly readClipboardContent?: (signal?: AbortSignal) => Promise<PiShellClipboardContent | null>;
111
+ readonly captureClipboardPaste?: () => PasteSource;
112
+ readonly pasteDiagnostics?: (event: PasteEvent) => void;
100
113
  }, startup?: PiShellHeaderOptions, agentDir?: string, extensionRenderers?: PiShellExtensionRendererResolver, sessionLayout?: "pinned" | "custom-viewport", imageAssets?: PiShellImageAssetResolver);
101
114
  setEditorPaddingX(padding: number): void;
102
115
  setAutocompleteMaxVisible(maxVisible: number): void;
@@ -171,11 +184,7 @@ export declare class OwnedUiSessionShellRoot implements PiTuiComponentPort {
171
184
  clearViewportPointerState(): void;
172
185
  setViewportOverlaySurfaces(surfaces: readonly PiTuiPointerSurface[] | null): void;
173
186
  viewportInputGeometryReady(width: number, height: number): boolean;
174
- handleViewportPreInput(data: string, allowWheel?: boolean, now?: number, editorActive?: boolean): {
175
- readonly data: string;
176
- readonly consumed: boolean;
177
- readonly copyText?: string;
178
- };
187
+ handleViewportPreInput(data: string, allowWheel?: boolean, now?: number, editorActive?: boolean): SessionViewportInputResult;
179
188
  layoutRoot(): PiTuiLayoutNode;
180
189
  transcriptComponent(id: string): PiShellTranscriptComponentPort | undefined;
181
190
  exitTranscript(width: number): string;