@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.
- package/dist/composition/owned-ui.d.ts +2 -0
- package/dist/composition/owned-ui.js +28 -11
- package/dist/contracts/owned-ui/image-attachments.d.ts +7 -0
- package/dist/contracts/owned-ui/image-attachments.js +7 -0
- package/dist/foundation/release/dependency-certification.js +65 -7
- package/dist/integrations/pi/components/owned-editor-ux.d.ts +8 -6
- package/dist/integrations/pi/components/owned-editor-ux.js +115 -26
- package/dist/integrations/pi/components/shell-editor-autocomplete.js +4 -0
- package/dist/integrations/pi/components/shell-presenters-transcript.js +15 -5
- package/dist/integrations/pi/components/shell-shared-facade.d.ts +14 -5
- package/dist/integrations/pi/components/tool-image-presentation.d.ts +21 -0
- package/dist/integrations/pi/components/tool-image-presentation.js +86 -0
- package/dist/integrations/pi/components/tool-result-adapter.d.ts +2 -2
- package/dist/integrations/pi/components/transcript-image-resolver.d.ts +7 -0
- package/dist/integrations/pi/components/transcript-image-resolver.js +19 -0
- package/dist/integrations/pi/components/upstream/components/scoped-models-selector.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/components/scoped-models-selector.js +3 -1
- package/dist/integrations/pi/components/upstream/components/tool-execution.d.ts +61 -0
- package/dist/integrations/pi/components/upstream/components/tool-execution.js +344 -0
- package/dist/integrations/pi/engine/adapter.d.ts +4 -1
- package/dist/integrations/pi/engine/adapter.js +66 -28
- package/dist/integrations/pi/engine/pending-delivery.d.ts +4 -0
- package/dist/integrations/pi/engine/pending-delivery.js +15 -6
- package/dist/integrations/pi/engine/tool-rendering.js +26 -3
- package/dist/integrations/pi/engine/transcript-image-assets.d.ts +11 -0
- package/dist/integrations/pi/engine/transcript-image-assets.js +86 -0
- package/dist/integrations/pi/session-ui/clipboard-diagnostics.d.ts +16 -0
- package/dist/integrations/pi/session-ui/clipboard-diagnostics.js +65 -0
- package/dist/integrations/pi/session-ui/editor-hyperlink-budget.d.ts +9 -0
- package/dist/integrations/pi/session-ui/editor-hyperlink-budget.js +23 -0
- package/dist/integrations/pi/session-ui/index.d.ts +1 -0
- package/dist/integrations/pi/session-ui/index.js +1 -0
- package/dist/integrations/pi/session-ui/paste-executor.d.ts +10 -0
- package/dist/integrations/pi/session-ui/paste-executor.js +232 -0
- package/dist/integrations/pi/session-ui/paste-helper.d.ts +1 -0
- package/dist/integrations/pi/session-ui/paste-helper.js +173 -0
- package/dist/integrations/pi/session-ui/paste-preparation-client.d.ts +16 -0
- package/dist/integrations/pi/session-ui/paste-preparation-client.js +159 -0
- package/dist/integrations/pi/session-ui/paste-protocol.d.ts +78 -0
- package/dist/integrations/pi/session-ui/paste-protocol.js +18 -0
- package/dist/integrations/pi/session-ui/paste-text-preparation.d.ts +20 -0
- package/dist/integrations/pi/session-ui/paste-text-preparation.js +134 -0
- package/dist/integrations/pi/session-ui/paste-text-worker.d.ts +1 -0
- package/dist/integrations/pi/session-ui/paste-text-worker.js +12 -0
- package/dist/integrations/pi/session-ui/path-chip-presentation.d.ts +7 -0
- package/dist/integrations/pi/session-ui/path-chip-presentation.js +24 -0
- package/dist/integrations/pi/session-ui/prompt-chips.d.ts +8 -1
- package/dist/integrations/pi/session-ui/prompt-chips.js +129 -149
- package/dist/integrations/pi/session-ui/response-copy-coordinator.d.ts +27 -0
- package/dist/integrations/pi/session-ui/response-copy-coordinator.js +189 -0
- package/dist/integrations/pi/session-ui/response-copy-helper.d.ts +1 -0
- package/dist/integrations/pi/session-ui/response-copy-helper.js +122 -0
- package/dist/integrations/pi/session-ui/response-copy-protocol.d.ts +56 -0
- package/dist/integrations/pi/session-ui/response-copy-protocol.js +5 -0
- package/dist/integrations/pi/session-ui/response-copy-transport.d.ts +31 -0
- package/dist/integrations/pi/session-ui/response-copy-transport.js +187 -0
- package/dist/integrations/pi/session-ui/session-shell-root.d.ts +15 -6
- package/dist/integrations/pi/session-ui/session-shell-root.js +25 -6
- package/dist/integrations/pi/session-ui/session-shell.js +100 -28
- package/dist/integrations/pi/session-ui/session-viewport-controller.d.ts +2 -2
- package/dist/integrations/pi/session-ui/session-viewport-controller.js +4 -3
- package/dist/integrations/pi/session-ui/system-clipboard.d.ts +2 -2
- package/dist/integrations/pi/session-ui/system-clipboard.js +26 -11
- package/dist/integrations/pi/tui-runtime/adapter.js +3 -1
- package/dist/native/darwin-arm64/manifest.json +1 -1
- package/dist/native/linux-x64/manifest.json +1 -1
- package/dist/native/win32-x64/manifest.json +2 -2
- package/dist/native/win32-x64/process-guardian.exe +0 -0
- package/dist/product-identity.d.ts +1 -1
- package/dist/product-identity.js +1 -1
- package/dist/product-identity.json +1 -0
- package/dist/ui/components/index.d.ts +1 -0
- package/dist/ui/components/index.js +1 -0
- package/dist/ui/components/selection-copy.d.ts +24 -0
- package/dist/ui/components/selection-copy.js +39 -0
- package/dist/ui/components/spans.d.ts +5 -2
- package/dist/ui/components/spans.js +12 -3
- package/dist/ui/components/text-selection.js +2 -0
- package/dist/ui/components/text.js +1 -1
- package/dist/ui/components/transcript-viewport.d.ts +5 -0
- package/dist/ui/components/transcript-viewport.js +100 -23
- package/dist/ui/components/visible-hyperlinks.js +40 -8
- package/docs/architecture/response-copy-delivery.md +83 -0
- package/docs/architecture/tool-image-presentation.md +62 -0
- package/docs/ci-release-runbook.md +19 -7
- package/docs/openspec-archive-automation.md +124 -0
- package/package.json +2 -1
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { fork } from "node:child_process";
|
|
2
|
+
import { ImageAttachmentError } from "../../../contracts/owned-ui/index.js";
|
|
3
|
+
import { MAX_SOURCE_IMAGE_BYTES } from "./image-source.js";
|
|
4
|
+
import { PASTE_CHUNK_UNITS, PASTE_REQUESTS, PASTE_STOP_MS, PASTE_TEXT_BYTES, pasteFragments } from "./paste-protocol.js";
|
|
5
|
+
const live = new Set();
|
|
6
|
+
const conversionQueue = [];
|
|
7
|
+
let converting;
|
|
8
|
+
function permitConversion() {
|
|
9
|
+
if (converting)
|
|
10
|
+
return;
|
|
11
|
+
const child = conversionQueue.shift();
|
|
12
|
+
if (!child)
|
|
13
|
+
return;
|
|
14
|
+
if (!live.has(child) || !child.connected) {
|
|
15
|
+
permitConversion();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
converting = child;
|
|
19
|
+
child.send({ kind: "convert" }, () => { });
|
|
20
|
+
}
|
|
21
|
+
/** Streams bounded fragments through a killable child; native/codec/filesystem work never runs on the UI thread. */
|
|
22
|
+
export function startPasteExecutor(content, signal, phase = () => { }, helper) {
|
|
23
|
+
const tooLarge = content?.kind === "text" && content.text.length > PASTE_TEXT_BYTES;
|
|
24
|
+
if (signal.aborted || live.size >= PASTE_REQUESTS || tooLarge) {
|
|
25
|
+
const result = Promise.reject(new ImageAttachmentError(signal.aborted ? "image-canceled" : tooLarge ? "paste-size" : "paste-busy"));
|
|
26
|
+
void result.catch(() => { });
|
|
27
|
+
return { result, stopped: Promise.resolve(), cancel() { } };
|
|
28
|
+
}
|
|
29
|
+
const source = new URL(import.meta.url).pathname.endsWith(".ts");
|
|
30
|
+
const entry = helper ?? new URL(source ? "./paste-helper.ts" : "./paste-helper.js", import.meta.url);
|
|
31
|
+
const options = {
|
|
32
|
+
execArgv: entry.pathname.endsWith(".ts") ? ["--import", "tsx"] : [],
|
|
33
|
+
stdio: ["ignore", "ignore", "ignore", "ipc"], serialization: "json", windowsHide: true,
|
|
34
|
+
detached: process.platform !== "win32",
|
|
35
|
+
};
|
|
36
|
+
const child = fork(entry, [], options);
|
|
37
|
+
const forceStop = () => {
|
|
38
|
+
// Concurrency: POSIX command fallbacks share this isolated process group; kill descendants too.
|
|
39
|
+
if (process.platform !== "win32" && child.pid !== undefined) {
|
|
40
|
+
try {
|
|
41
|
+
process.kill(-child.pid, "SIGKILL");
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
child.kill("SIGKILL");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else
|
|
48
|
+
child.kill("SIGKILL");
|
|
49
|
+
child.unref();
|
|
50
|
+
child.channel?.unref();
|
|
51
|
+
};
|
|
52
|
+
live.add(child);
|
|
53
|
+
let input = sourceMessages(content);
|
|
54
|
+
let canceled = false, done = false;
|
|
55
|
+
let header;
|
|
56
|
+
let payload = "", bytes = 0;
|
|
57
|
+
let paths = [];
|
|
58
|
+
let failure;
|
|
59
|
+
let stopTimer;
|
|
60
|
+
const phases = new Set();
|
|
61
|
+
let resolve, reject, stopped;
|
|
62
|
+
const result = new Promise((ok, fail) => { resolve = ok; reject = fail; });
|
|
63
|
+
const exit = new Promise(end => { stopped = end; });
|
|
64
|
+
const send = (message) => {
|
|
65
|
+
if (!child.connected || canceled)
|
|
66
|
+
return;
|
|
67
|
+
child.send(message, error => { if (error)
|
|
68
|
+
cancel(); });
|
|
69
|
+
};
|
|
70
|
+
const cancel = () => {
|
|
71
|
+
if (canceled || !live.has(child))
|
|
72
|
+
return;
|
|
73
|
+
canceled = true;
|
|
74
|
+
input = undefined;
|
|
75
|
+
payload = "";
|
|
76
|
+
paths = [];
|
|
77
|
+
reject(failure ?? (signal.reason instanceof ImageAttachmentError ? signal.reason : new ImageAttachmentError("image-canceled")));
|
|
78
|
+
const queued = conversionQueue.indexOf(child);
|
|
79
|
+
if (queued >= 0)
|
|
80
|
+
conversionQueue.splice(queued, 1);
|
|
81
|
+
if (child.connected)
|
|
82
|
+
child.send({ kind: "cancel" }, () => { });
|
|
83
|
+
stopTimer = setTimeout(forceStop, PASTE_STOP_MS);
|
|
84
|
+
stopTimer.unref();
|
|
85
|
+
};
|
|
86
|
+
const fail = (code = "paste-unavailable") => { failure = new ImageAttachmentError(code); cancel(); };
|
|
87
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
88
|
+
child.on("message", value => {
|
|
89
|
+
if (canceled)
|
|
90
|
+
return;
|
|
91
|
+
const message = value;
|
|
92
|
+
try {
|
|
93
|
+
if (message?.kind === "ready") {
|
|
94
|
+
const next = input?.next();
|
|
95
|
+
if (!next || next.done) {
|
|
96
|
+
fail();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
send(next.value);
|
|
100
|
+
}
|
|
101
|
+
else if (message?.kind === "phase") {
|
|
102
|
+
if (!["acquired-text", "acquired-image", "classifying", "path-fallback", "prepared"].includes(message.phase) || phases.has(message.phase)) {
|
|
103
|
+
fail();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
phases.add(message.phase);
|
|
107
|
+
phase(message.phase, message.bytes);
|
|
108
|
+
// Concurrency: lifecycle observers may synchronously cancel; never grant new conversion work afterward.
|
|
109
|
+
if (canceled)
|
|
110
|
+
return;
|
|
111
|
+
if (message.phase === "acquired-image") {
|
|
112
|
+
conversionQueue.push(child);
|
|
113
|
+
permitConversion();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else if (message?.kind === "output" && !header) {
|
|
117
|
+
if (!["text", "url", "paths", "image", "empty"].includes(message.type) || (message.label?.length ?? 0) > 256
|
|
118
|
+
|| (message.mimeType?.length ?? 0) > 256) {
|
|
119
|
+
fail();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
header = message;
|
|
123
|
+
input = undefined;
|
|
124
|
+
send({ kind: "next" });
|
|
125
|
+
}
|
|
126
|
+
else if (message?.kind === "data" && header && ["text", "url", "image"].includes(header.type)) {
|
|
127
|
+
if (typeof message.data !== "string" || message.data.length > PASTE_CHUNK_UNITS) {
|
|
128
|
+
fail();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
bytes += Buffer.byteLength(message.data);
|
|
132
|
+
if (bytes > (header.type === "image" ? 8 * 1024 * 1024 : PASTE_TEXT_BYTES)) {
|
|
133
|
+
fail("paste-size");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// Performance: retain a rope of bounded fragments; no giant JSON parse/join is needed on the UI thread.
|
|
137
|
+
payload += message.data;
|
|
138
|
+
send({ kind: "next" });
|
|
139
|
+
}
|
|
140
|
+
else if (message?.kind === "path" && header?.type === "paths") {
|
|
141
|
+
const item = message.path;
|
|
142
|
+
if (!item || !["file", "folder"].includes(item.kind) || typeof item.fullPath !== "string" || item.fullPath.length > 32_768) {
|
|
143
|
+
fail();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
bytes += Buffer.byteLength(item.fullPath);
|
|
147
|
+
if (bytes > PASTE_TEXT_BYTES) {
|
|
148
|
+
fail("paste-size");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
paths.push(item);
|
|
152
|
+
send({ kind: "next" });
|
|
153
|
+
}
|
|
154
|
+
else if (message?.kind === "done" && header) {
|
|
155
|
+
done = true;
|
|
156
|
+
stopTimer = setTimeout(forceStop, PASTE_STOP_MS);
|
|
157
|
+
stopTimer.unref();
|
|
158
|
+
}
|
|
159
|
+
else if (message?.kind === "error") {
|
|
160
|
+
const candidate = new ImageAttachmentError(message.code);
|
|
161
|
+
failure = candidate.message ? candidate : new ImageAttachmentError("paste-unavailable");
|
|
162
|
+
cancel();
|
|
163
|
+
}
|
|
164
|
+
else
|
|
165
|
+
fail();
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
fail(error instanceof ImageAttachmentError ? error.code : "paste-unavailable");
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
child.once("error", () => fail());
|
|
172
|
+
const cleanup = () => {
|
|
173
|
+
if (!live.delete(child))
|
|
174
|
+
return;
|
|
175
|
+
if (canceled)
|
|
176
|
+
forceStop();
|
|
177
|
+
input = undefined;
|
|
178
|
+
if (stopTimer)
|
|
179
|
+
clearTimeout(stopTimer);
|
|
180
|
+
signal.removeEventListener("abort", cancel);
|
|
181
|
+
child.removeAllListeners("message");
|
|
182
|
+
const queued = conversionQueue.indexOf(child);
|
|
183
|
+
if (queued >= 0)
|
|
184
|
+
conversionQueue.splice(queued, 1);
|
|
185
|
+
if (converting === child)
|
|
186
|
+
converting = undefined;
|
|
187
|
+
permitConversion();
|
|
188
|
+
if (!canceled && done && header) {
|
|
189
|
+
if (header.type === "empty")
|
|
190
|
+
resolve(null);
|
|
191
|
+
else if (header.type === "text")
|
|
192
|
+
resolve({ kind: "text", text: payload, ...(header.label === undefined ? {} : { label: header.label }) });
|
|
193
|
+
else if (header.type === "url")
|
|
194
|
+
resolve({ kind: "url", url: payload, label: header.label ?? "" });
|
|
195
|
+
else if (header.type === "paths")
|
|
196
|
+
resolve({ kind: "paths", paths });
|
|
197
|
+
else if (header.mimeType && Number.isSafeInteger(header.width) && Number.isSafeInteger(header.height) && header.width > 0 && header.height > 0)
|
|
198
|
+
resolve({
|
|
199
|
+
kind: "image", data: payload, mimeType: header.mimeType, width: header.width, height: header.height, transformed: header.transformed === true,
|
|
200
|
+
});
|
|
201
|
+
else
|
|
202
|
+
reject(new ImageAttachmentError("paste-unavailable"));
|
|
203
|
+
}
|
|
204
|
+
else
|
|
205
|
+
reject(failure ?? new ImageAttachmentError(canceled ? "image-canceled" : "paste-unavailable"));
|
|
206
|
+
payload = "";
|
|
207
|
+
paths = [];
|
|
208
|
+
stopped();
|
|
209
|
+
try {
|
|
210
|
+
phase("cleanup");
|
|
211
|
+
}
|
|
212
|
+
catch { /* Invariant: cleanup observation cannot retain an exited executor. */ }
|
|
213
|
+
};
|
|
214
|
+
child.once("exit", cleanup);
|
|
215
|
+
child.once("close", cleanup);
|
|
216
|
+
void result.catch(() => { });
|
|
217
|
+
return { result, stopped: exit, cancel };
|
|
218
|
+
}
|
|
219
|
+
function* sourceMessages(content) {
|
|
220
|
+
yield { kind: "begin", source: content?.kind ?? "native", ...(content?.kind === "image" ? { mimeType: content.mimeType } : {}) };
|
|
221
|
+
if (content !== undefined) {
|
|
222
|
+
const data = content.kind === "text" ? content.text : content.data;
|
|
223
|
+
let bytes = 0;
|
|
224
|
+
for (const fragment of pasteFragments(data)) {
|
|
225
|
+
bytes += Buffer.byteLength(fragment);
|
|
226
|
+
if (bytes > (content.kind === "text" ? PASTE_TEXT_BYTES : Math.ceil(MAX_SOURCE_IMAGE_BYTES / 3) * 4 + 4))
|
|
227
|
+
throw new ImageAttachmentError(content.kind === "text" ? "paste-size" : "image-source-size");
|
|
228
|
+
yield { kind: "data", data: fragment };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
yield { kind: "finish" };
|
|
232
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { Worker } from "node:worker_threads";
|
|
2
|
+
import { ImageAttachmentError } from "../../../contracts/owned-ui/index.js";
|
|
3
|
+
import { readSystemClipboardContent } from "./system-clipboard.js";
|
|
4
|
+
import { MAX_SOURCE_IMAGE_BYTES } from "./image-source.js";
|
|
5
|
+
import { preparePasteText } from "./paste-text-preparation.js";
|
|
6
|
+
import { PASTE_CHUNK_UNITS, PASTE_TEXT_BYTES, pasteFragments } from "./paste-protocol.js";
|
|
7
|
+
const abort = new AbortController();
|
|
8
|
+
let header;
|
|
9
|
+
let fragments = [], bytes = 0;
|
|
10
|
+
let image;
|
|
11
|
+
let output;
|
|
12
|
+
let finished = false;
|
|
13
|
+
function send(message) { process.send?.(message); }
|
|
14
|
+
function fail(error) {
|
|
15
|
+
if (finished)
|
|
16
|
+
return;
|
|
17
|
+
finished = true;
|
|
18
|
+
process.send?.({ kind: "error", code: error instanceof ImageAttachmentError ? error.code : "paste-unavailable" }, () => process.exit(1));
|
|
19
|
+
}
|
|
20
|
+
function workerFor(name, data) {
|
|
21
|
+
const source = new URL(import.meta.url).pathname.endsWith(".ts");
|
|
22
|
+
const entry = new URL(`./${name}.${source ? "ts" : "js"}`, import.meta.url);
|
|
23
|
+
const worker = source
|
|
24
|
+
? new Worker(`import('tsx/esm/api').then(({ tsImport }) => tsImport(${JSON.stringify(entry.href)}, ${JSON.stringify(import.meta.url)}))`, { eval: true, workerData: data, stdout: true, stderr: true })
|
|
25
|
+
: new Worker(entry, { workerData: data, stdout: true, stderr: true });
|
|
26
|
+
worker.stdout.resume();
|
|
27
|
+
worker.stderr.resume();
|
|
28
|
+
return worker;
|
|
29
|
+
}
|
|
30
|
+
async function classifyText(text) {
|
|
31
|
+
const worker = workerFor("paste-text-worker", text);
|
|
32
|
+
return new Promise(resolve => {
|
|
33
|
+
let done = false;
|
|
34
|
+
const complete = (prepared) => {
|
|
35
|
+
if (done)
|
|
36
|
+
return;
|
|
37
|
+
done = true;
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
// Concurrency: a worker may be in a blocked native filesystem call. Never await its termination here;
|
|
40
|
+
// this child exits after transferring the safe text fallback and kills all its own threads.
|
|
41
|
+
void worker.terminate().catch(() => { });
|
|
42
|
+
if (!prepared)
|
|
43
|
+
send({ kind: "phase", phase: "path-fallback" });
|
|
44
|
+
resolve(prepared ?? preparePasteText(text, true));
|
|
45
|
+
};
|
|
46
|
+
const timer = setTimeout(() => complete(), 1_000);
|
|
47
|
+
worker.once("message", message => complete(message?.ok ? message.value : undefined));
|
|
48
|
+
worker.once("error", () => complete());
|
|
49
|
+
worker.once("exit", () => complete());
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
function imagePreparation(source) {
|
|
53
|
+
const worker = workerFor("image-worker", { kind: "prepare", source });
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
let done = false;
|
|
56
|
+
worker.once("message", message => {
|
|
57
|
+
done = true;
|
|
58
|
+
if (message?.ok)
|
|
59
|
+
resolve({ kind: "image", ...message.value });
|
|
60
|
+
else
|
|
61
|
+
reject(new ImageAttachmentError(message?.code ?? "image-codec"));
|
|
62
|
+
void worker.terminate().catch(() => { });
|
|
63
|
+
});
|
|
64
|
+
worker.once("error", () => reject(new ImageAttachmentError("image-codec")));
|
|
65
|
+
worker.once("exit", () => { if (!done)
|
|
66
|
+
reject(new ImageAttachmentError("image-codec")); });
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
function* outputMessages(value) {
|
|
70
|
+
if (value === null)
|
|
71
|
+
yield { kind: "output", type: "empty" };
|
|
72
|
+
else if (value.kind === "paths") {
|
|
73
|
+
yield { kind: "output", type: "paths" };
|
|
74
|
+
for (const path of value.paths)
|
|
75
|
+
yield { kind: "path", path };
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
if (value.kind === "image")
|
|
79
|
+
yield { kind: "output", type: "image", mimeType: value.mimeType, width: value.width, height: value.height, transformed: value.transformed };
|
|
80
|
+
else
|
|
81
|
+
yield { kind: "output", type: value.kind, ...(value.label === undefined ? {} : { label: value.label }) };
|
|
82
|
+
const data = value.kind === "text" ? value.text : value.kind === "url" ? value.url : value.data;
|
|
83
|
+
for (const fragment of pasteFragments(data))
|
|
84
|
+
yield { kind: "data", data: fragment };
|
|
85
|
+
}
|
|
86
|
+
yield { kind: "done" };
|
|
87
|
+
}
|
|
88
|
+
function present(value) {
|
|
89
|
+
const bytes = value === null ? 0 : value.kind === "paths" ? value.paths.reduce((sum, path) => sum + Buffer.byteLength(path.fullPath), 0)
|
|
90
|
+
: Buffer.byteLength(value.kind === "image" ? value.data : value.kind === "url" ? value.url : value.text);
|
|
91
|
+
if (value !== null && value.kind !== "image" && bytes > PASTE_TEXT_BYTES)
|
|
92
|
+
throw new ImageAttachmentError("paste-size");
|
|
93
|
+
send({ kind: "phase", phase: "prepared", bytes });
|
|
94
|
+
output = outputMessages(value);
|
|
95
|
+
nextOutput();
|
|
96
|
+
}
|
|
97
|
+
function nextOutput() {
|
|
98
|
+
const next = output?.next();
|
|
99
|
+
if (!next || next.done)
|
|
100
|
+
throw new Error("protocol");
|
|
101
|
+
if (next.value.kind === "done") {
|
|
102
|
+
finished = true;
|
|
103
|
+
output = undefined;
|
|
104
|
+
process.send?.(next.value, () => process.exit(0));
|
|
105
|
+
}
|
|
106
|
+
else
|
|
107
|
+
send(next.value);
|
|
108
|
+
}
|
|
109
|
+
async function receive(message) {
|
|
110
|
+
if (message?.kind === "cancel") {
|
|
111
|
+
abort.abort();
|
|
112
|
+
fragments = [];
|
|
113
|
+
image = undefined;
|
|
114
|
+
output = undefined;
|
|
115
|
+
// Concurrency: allow an outstanding command read's AbortSignal to terminate its subprocess before exit.
|
|
116
|
+
setTimeout(() => process.exit(1), 100).unref();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (abort.signal.aborted || finished)
|
|
120
|
+
return;
|
|
121
|
+
if (message?.kind === "begin" && !header) {
|
|
122
|
+
if (!["native", "text", "image"].includes(message.source) || (message.mimeType?.length ?? 0) > 256)
|
|
123
|
+
throw new Error("protocol");
|
|
124
|
+
header = message;
|
|
125
|
+
send({ kind: "ready" });
|
|
126
|
+
}
|
|
127
|
+
else if (message?.kind === "data" && header && header.source !== "native") {
|
|
128
|
+
if (typeof message.data !== "string" || message.data.length > PASTE_CHUNK_UNITS)
|
|
129
|
+
throw new Error("protocol");
|
|
130
|
+
bytes += Buffer.byteLength(message.data);
|
|
131
|
+
if (bytes > (header.source === "text" ? PASTE_TEXT_BYTES : Math.ceil(MAX_SOURCE_IMAGE_BYTES / 3) * 4 + 4))
|
|
132
|
+
throw new ImageAttachmentError(header.source === "text" ? "paste-size" : "image-source-size");
|
|
133
|
+
fragments.push(message.data);
|
|
134
|
+
send({ kind: "ready" });
|
|
135
|
+
}
|
|
136
|
+
else if (message?.kind === "finish" && header) {
|
|
137
|
+
const content = header.source === "native" ? await readSystemClipboardContent(abort.signal, true)
|
|
138
|
+
: header.source === "image" ? { kind: "image", mimeType: header.mimeType ?? "", data: fragments.join("") }
|
|
139
|
+
: { kind: "text", text: fragments.join("") };
|
|
140
|
+
fragments = [];
|
|
141
|
+
if (abort.signal.aborted)
|
|
142
|
+
return;
|
|
143
|
+
if (content?.kind === "image") {
|
|
144
|
+
image = content;
|
|
145
|
+
send({ kind: "phase", phase: "acquired-image", bytes: Buffer.byteLength(content.data) });
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
const text = content?.text ?? "";
|
|
149
|
+
const size = Buffer.byteLength(text);
|
|
150
|
+
if (size > PASTE_TEXT_BYTES)
|
|
151
|
+
throw new ImageAttachmentError("paste-size");
|
|
152
|
+
send({ kind: "phase", phase: "acquired-text", bytes: size });
|
|
153
|
+
if (!text) {
|
|
154
|
+
present(null);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
send({ kind: "phase", phase: "classifying", bytes: size });
|
|
158
|
+
present(await classifyText(text));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else if (message?.kind === "convert" && image) {
|
|
162
|
+
const source = image;
|
|
163
|
+
image = undefined;
|
|
164
|
+
present(await imagePreparation(source));
|
|
165
|
+
}
|
|
166
|
+
else if (message?.kind === "next" && output)
|
|
167
|
+
nextOutput();
|
|
168
|
+
else
|
|
169
|
+
throw new Error("protocol");
|
|
170
|
+
}
|
|
171
|
+
process.on("message", message => { void receive(message).catch(fail); });
|
|
172
|
+
process.on("disconnect", () => { abort.abort(); process.exit(0); });
|
|
173
|
+
send({ kind: "ready" });
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type PasteEvent, type PasteSource, type PreparedPaste } from "./paste-protocol.js";
|
|
2
|
+
export interface PreparedPasteJob<T> {
|
|
3
|
+
readonly result: Promise<T>;
|
|
4
|
+
cancel(): void;
|
|
5
|
+
complete(): void;
|
|
6
|
+
isCurrent(): boolean;
|
|
7
|
+
}
|
|
8
|
+
/** Keeps eight distinct paste transactions alive through insertion; canceled executors retain capacity until fenced. */
|
|
9
|
+
export declare class PastePreparationClient {
|
|
10
|
+
#private;
|
|
11
|
+
private readonly onEvent?;
|
|
12
|
+
constructor(onEvent?: ((event: PasteEvent) => void) | undefined);
|
|
13
|
+
start<T>(source: PasteSource, adopt: (value: PreparedPaste, signal: AbortSignal) => Promise<T> | T, onImage: () => void, onText?: () => void): PreparedPasteJob<T>;
|
|
14
|
+
reset(): void;
|
|
15
|
+
dispose(): Promise<void>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { ImageAttachmentError } from "../../../contracts/owned-ui/index.js";
|
|
2
|
+
import { startPasteExecutor } from "./paste-executor.js";
|
|
3
|
+
import { PASTE_READ_MS, PASTE_REQUESTS, PASTE_STOP_MS, PASTE_TOTAL_MS } from "./paste-protocol.js";
|
|
4
|
+
/** Keeps eight distinct paste transactions alive through insertion; canceled executors retain capacity until fenced. */
|
|
5
|
+
export class PastePreparationClient {
|
|
6
|
+
onEvent;
|
|
7
|
+
#active = new Set();
|
|
8
|
+
#sequence = 0;
|
|
9
|
+
#disposed = false;
|
|
10
|
+
constructor(onEvent) {
|
|
11
|
+
this.onEvent = onEvent;
|
|
12
|
+
}
|
|
13
|
+
start(source, adopt, onImage, onText = () => { }) {
|
|
14
|
+
const controller = new AbortController();
|
|
15
|
+
const id = ++this.#sequence;
|
|
16
|
+
const admitted = performance.now();
|
|
17
|
+
let resolve, reject, finish;
|
|
18
|
+
let settled = false;
|
|
19
|
+
let executor;
|
|
20
|
+
const committed = new Promise(done => { finish = done; });
|
|
21
|
+
const result = new Promise((ok, fail) => { resolve = ok; reject = fail; });
|
|
22
|
+
const emit = (phase, bytes, outcome) => {
|
|
23
|
+
const atMs = performance.now();
|
|
24
|
+
try {
|
|
25
|
+
this.onEvent?.({ request: id, phase, atMs, elapsedMs: atMs - admitted, pending: this.#active.size, transport: source.kind === "text" ? "terminal" : source.kind,
|
|
26
|
+
...(bytes === undefined ? {} : { bytes }), ...(outcome === undefined ? {} : { outcome }) });
|
|
27
|
+
}
|
|
28
|
+
catch { /* Invariant: observations never affect input. */ }
|
|
29
|
+
};
|
|
30
|
+
const total = setTimeout(() => controller.abort(new ImageAttachmentError("paste-timeout")), PASTE_TOTAL_MS);
|
|
31
|
+
let reading = source.kind === "text" ? undefined
|
|
32
|
+
: setTimeout(() => controller.abort(new ImageAttachmentError("paste-timeout")), PASTE_READ_MS);
|
|
33
|
+
const clearRead = () => { if (reading)
|
|
34
|
+
clearTimeout(reading); reading = undefined; };
|
|
35
|
+
const complete = (outcome = "ready") => {
|
|
36
|
+
if (settled)
|
|
37
|
+
return;
|
|
38
|
+
settled = true;
|
|
39
|
+
clearTimeout(total);
|
|
40
|
+
clearRead();
|
|
41
|
+
finish();
|
|
42
|
+
emit("settled", undefined, outcome);
|
|
43
|
+
};
|
|
44
|
+
controller.signal.addEventListener("abort", () => {
|
|
45
|
+
if (settled)
|
|
46
|
+
return;
|
|
47
|
+
settled = true;
|
|
48
|
+
executor?.cancel();
|
|
49
|
+
clearTimeout(total);
|
|
50
|
+
clearRead();
|
|
51
|
+
finish();
|
|
52
|
+
reject(controller.signal.reason ?? new ImageAttachmentError("image-canceled"));
|
|
53
|
+
const reason = controller.signal.reason;
|
|
54
|
+
emit("settled", undefined, reason instanceof ImageAttachmentError && reason.code === "paste-timeout" ? "timed-out"
|
|
55
|
+
: reason instanceof ImageAttachmentError && reason.code === "paste-busy" ? "failed" : "canceled");
|
|
56
|
+
}, { once: true });
|
|
57
|
+
const job = {
|
|
58
|
+
result, complete: () => complete(), isCurrent: () => !controller.signal.aborted,
|
|
59
|
+
cancel: () => { if (!settled)
|
|
60
|
+
controller.abort(new ImageAttachmentError("image-canceled")); },
|
|
61
|
+
};
|
|
62
|
+
void result.catch(() => { });
|
|
63
|
+
if (this.#disposed || this.#active.size >= PASTE_REQUESTS) {
|
|
64
|
+
controller.abort(new ImageAttachmentError(this.#disposed ? "image-canceled" : "paste-busy"));
|
|
65
|
+
return job;
|
|
66
|
+
}
|
|
67
|
+
let stopped;
|
|
68
|
+
const active = { controller, stopped: new Promise(done => { stopped = done; }) };
|
|
69
|
+
this.#active.add(active);
|
|
70
|
+
emit("admitted");
|
|
71
|
+
// Concurrency: admission/reservation occurs in the input turn; startup and preparation get a later turn.
|
|
72
|
+
setImmediate(() => {
|
|
73
|
+
void (async () => {
|
|
74
|
+
try {
|
|
75
|
+
if (controller.signal.aborted)
|
|
76
|
+
return;
|
|
77
|
+
let content;
|
|
78
|
+
if (source.kind !== "text" && source.before) {
|
|
79
|
+
emit("predecessor");
|
|
80
|
+
if (!await source.before(controller.signal))
|
|
81
|
+
throw new ImageAttachmentError("paste-write-failed");
|
|
82
|
+
}
|
|
83
|
+
if (controller.signal.aborted)
|
|
84
|
+
return;
|
|
85
|
+
emit("acquiring");
|
|
86
|
+
if (source.kind === "text")
|
|
87
|
+
content = { kind: "text", text: source.text };
|
|
88
|
+
else if (source.kind === "provided") {
|
|
89
|
+
const read = await source.read(controller.signal);
|
|
90
|
+
clearRead();
|
|
91
|
+
if (controller.signal.aborted)
|
|
92
|
+
return;
|
|
93
|
+
if (read?.kind !== "image")
|
|
94
|
+
onText();
|
|
95
|
+
if (read === null) {
|
|
96
|
+
emit("inserting");
|
|
97
|
+
resolve(await adopt(null, controller.signal));
|
|
98
|
+
await committed;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
content = read;
|
|
102
|
+
}
|
|
103
|
+
executor = startPasteExecutor(content, controller.signal, (phase, bytes) => {
|
|
104
|
+
// Protocol: helper exit precedes adoption; only the request's finally block reports complete cleanup.
|
|
105
|
+
if (phase === "cleanup")
|
|
106
|
+
return;
|
|
107
|
+
if (phase === "acquired-text" || phase === "acquired-image")
|
|
108
|
+
clearRead();
|
|
109
|
+
if (controller.signal.aborted)
|
|
110
|
+
return;
|
|
111
|
+
if (phase === "acquired-image")
|
|
112
|
+
onImage();
|
|
113
|
+
if (phase === "acquired-text")
|
|
114
|
+
onText();
|
|
115
|
+
emit(phase, bytes);
|
|
116
|
+
});
|
|
117
|
+
const value = await executor.result;
|
|
118
|
+
if (controller.signal.aborted)
|
|
119
|
+
return;
|
|
120
|
+
emit("inserting");
|
|
121
|
+
const insertion = await adopt(value, controller.signal);
|
|
122
|
+
if (controller.signal.aborted)
|
|
123
|
+
return;
|
|
124
|
+
resolve(insertion);
|
|
125
|
+
await committed;
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (!controller.signal.aborted) {
|
|
129
|
+
reject(error instanceof ImageAttachmentError ? error : new ImageAttachmentError("paste-unavailable"));
|
|
130
|
+
complete("failed");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
// Invariant: capacity cannot be reused while an old native/codec executor can still consume resources.
|
|
135
|
+
await executor?.stopped;
|
|
136
|
+
clearTimeout(total);
|
|
137
|
+
clearRead();
|
|
138
|
+
this.#active.delete(active);
|
|
139
|
+
stopped();
|
|
140
|
+
emit("cleanup");
|
|
141
|
+
}
|
|
142
|
+
})();
|
|
143
|
+
});
|
|
144
|
+
return job;
|
|
145
|
+
}
|
|
146
|
+
reset() {
|
|
147
|
+
for (const active of this.#active)
|
|
148
|
+
active.controller.abort(new ImageAttachmentError("image-canceled"));
|
|
149
|
+
}
|
|
150
|
+
async dispose() {
|
|
151
|
+
this.#disposed = true;
|
|
152
|
+
this.reset();
|
|
153
|
+
let timer;
|
|
154
|
+
await Promise.race([Promise.all([...this.#active].map(active => active.stopped)),
|
|
155
|
+
new Promise(resolve => { timer = setTimeout(resolve, PASTE_STOP_MS); timer.unref(); })]);
|
|
156
|
+
if (timer)
|
|
157
|
+
clearTimeout(timer);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { PiShellClipboardContent } from "../components/index.js";
|
|
2
|
+
import type { PreparedPasteText, ClipboardPath } from "./paste-text-preparation.js";
|
|
3
|
+
import type { PreparedImage } from "./image-preparation.js";
|
|
4
|
+
export declare const PASTE_READ_MS = 5000;
|
|
5
|
+
export declare const PASTE_TOTAL_MS = 15000;
|
|
6
|
+
export declare const PASTE_STOP_MS = 250;
|
|
7
|
+
export declare const PASTE_TEXT_BYTES: number;
|
|
8
|
+
export declare const PASTE_CHUNK_UNITS = 16384;
|
|
9
|
+
export declare const PASTE_REQUESTS = 8;
|
|
10
|
+
export type PreparedPaste = PreparedPasteText | ({
|
|
11
|
+
readonly kind: "image";
|
|
12
|
+
} & PreparedImage) | null;
|
|
13
|
+
/** The predecessor is captured at admission; a later copy cannot change what this paste waits for. */
|
|
14
|
+
export type PasteSource = {
|
|
15
|
+
readonly kind: "native";
|
|
16
|
+
readonly before?: (signal: AbortSignal) => Promise<boolean>;
|
|
17
|
+
} | {
|
|
18
|
+
readonly kind: "provided";
|
|
19
|
+
readonly read: (signal: AbortSignal) => Promise<PiShellClipboardContent | null>;
|
|
20
|
+
readonly before?: (signal: AbortSignal) => Promise<boolean>;
|
|
21
|
+
} | {
|
|
22
|
+
readonly kind: "text";
|
|
23
|
+
readonly text: string;
|
|
24
|
+
};
|
|
25
|
+
export interface PasteEvent {
|
|
26
|
+
readonly request: number;
|
|
27
|
+
readonly phase: "receipt" | "framing" | "admitted" | "predecessor" | "acquiring" | "acquired-text" | "acquired-image" | "classifying" | "path-fallback" | "prepared" | "inserting" | "settled" | "cleanup";
|
|
28
|
+
readonly atMs: number;
|
|
29
|
+
readonly elapsedMs?: number;
|
|
30
|
+
readonly pending: number;
|
|
31
|
+
readonly transport?: "native" | "terminal" | "provided";
|
|
32
|
+
readonly bytes?: number;
|
|
33
|
+
readonly outcome?: "ready" | "failed" | "canceled" | "timed-out";
|
|
34
|
+
}
|
|
35
|
+
export type PasteHelperInput = {
|
|
36
|
+
readonly kind: "begin";
|
|
37
|
+
readonly source: "native" | "text" | "image";
|
|
38
|
+
readonly mimeType?: string;
|
|
39
|
+
} | {
|
|
40
|
+
readonly kind: "data";
|
|
41
|
+
readonly data: string;
|
|
42
|
+
} | {
|
|
43
|
+
readonly kind: "finish";
|
|
44
|
+
} | {
|
|
45
|
+
readonly kind: "convert";
|
|
46
|
+
} | {
|
|
47
|
+
readonly kind: "next";
|
|
48
|
+
} | {
|
|
49
|
+
readonly kind: "cancel";
|
|
50
|
+
};
|
|
51
|
+
export type PasteHelperOutput = {
|
|
52
|
+
readonly kind: "ready";
|
|
53
|
+
} | {
|
|
54
|
+
readonly kind: "phase";
|
|
55
|
+
readonly phase: "acquired-text" | "acquired-image" | "classifying" | "path-fallback" | "prepared";
|
|
56
|
+
readonly bytes?: number;
|
|
57
|
+
} | {
|
|
58
|
+
readonly kind: "output";
|
|
59
|
+
readonly type: "text" | "url" | "paths" | "image" | "empty";
|
|
60
|
+
readonly label?: string;
|
|
61
|
+
readonly mimeType?: string;
|
|
62
|
+
readonly width?: number;
|
|
63
|
+
readonly height?: number;
|
|
64
|
+
readonly transformed?: boolean;
|
|
65
|
+
} | {
|
|
66
|
+
readonly kind: "data";
|
|
67
|
+
readonly data: string;
|
|
68
|
+
} | {
|
|
69
|
+
readonly kind: "path";
|
|
70
|
+
readonly path: ClipboardPath;
|
|
71
|
+
} | {
|
|
72
|
+
readonly kind: "done";
|
|
73
|
+
} | {
|
|
74
|
+
readonly kind: "error";
|
|
75
|
+
readonly code: string;
|
|
76
|
+
};
|
|
77
|
+
/** Split without breaking UTF-16 surrogate pairs, so per-fragment UTF-8 accounting is exact. */
|
|
78
|
+
export declare function pasteFragments(text: string): Generator<string>;
|