@timurproko/a1 0.1.8-dev.282 → 0.1.8-dev.289
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/bin/ui.js +14 -2
- package/dist/contracts/owned-ui/image-attachments.d.ts +29 -0
- package/dist/contracts/owned-ui/image-attachments.js +60 -0
- package/dist/contracts/owned-ui/index.d.ts +1 -0
- package/dist/contracts/owned-ui/index.js +1 -0
- package/dist/contracts/owned-ui/validation.js +2 -9
- package/dist/features/owned-ui/run.js +16 -1
- package/dist/foundation/release/bootstrap.js +2 -1
- package/dist/foundation/terminal-cleanup/fatal-exit.d.ts +12 -0
- package/dist/foundation/terminal-cleanup/fatal-exit.js +85 -0
- package/dist/foundation/terminal-cleanup/index.d.ts +2 -0
- package/dist/foundation/terminal-cleanup/index.js +2 -0
- package/dist/foundation/terminal-cleanup/terminal-reset.d.ts +4 -0
- package/dist/foundation/terminal-cleanup/terminal-reset.js +40 -0
- package/dist/integrations/pi/components/owned-editor-ux.d.ts +6 -0
- package/dist/integrations/pi/components/owned-editor-ux.js +71 -0
- package/dist/integrations/pi/components/shell-editor-autocomplete.js +2 -0
- package/dist/integrations/pi/components/shell-shared-facade.d.ts +6 -1
- package/dist/integrations/pi/session-ui/clipboard-image.d.ts +2 -2
- package/dist/integrations/pi/session-ui/clipboard-image.js +13 -4
- package/dist/integrations/pi/session-ui/image-preparation-client.d.ts +20 -0
- package/dist/integrations/pi/session-ui/image-preparation-client.js +145 -0
- package/dist/integrations/pi/session-ui/image-preparation.d.ts +18 -0
- package/dist/integrations/pi/session-ui/image-preparation.js +126 -0
- package/dist/integrations/pi/session-ui/image-source.d.ts +13 -0
- package/dist/integrations/pi/session-ui/image-source.js +171 -0
- package/dist/integrations/pi/session-ui/image-worker.d.ts +17 -0
- package/dist/integrations/pi/session-ui/image-worker.js +23 -0
- package/dist/integrations/pi/session-ui/prompt-chips.d.ts +11 -2
- package/dist/integrations/pi/session-ui/prompt-chips.js +114 -4
- package/dist/integrations/pi/session-ui/session-shell-root.d.ts +8 -3
- package/dist/integrations/pi/session-ui/session-shell-root.js +27 -2
- package/dist/integrations/pi/session-ui/session-shell.js +179 -52
- package/dist/integrations/pi/session-ui/session-viewport-controller.js +17 -7
- package/dist/integrations/pi/session-ui/system-clipboard.d.ts +2 -2
- package/dist/integrations/pi/session-ui/system-clipboard.js +25 -14
- package/dist/integrations/pi/tui-runtime/adapter.js +13 -2
- package/dist/native/darwin-arm64/manifest.json +1 -1
- package/dist/native/linux-x64/manifest.json +3 -3
- package/dist/native/linux-x64/process-guardian +0 -0
- package/dist/native/win32-x64/manifest.json +2 -2
- package/dist/native/win32-x64/process-guardian.exe +0 -0
- package/dist/runtime-payload-inventory.json +22 -18
- package/docs/architecture/boundaries.md +2 -1
- package/package.json +2 -1
package/bin/ui.js
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
const startup = await import("../dist/foundation/startup/index.js");
|
|
4
4
|
startup.enableEnvironmentCompileCache(process.env);
|
|
5
|
+
const profile = process.env.A1_LAUNCH_PROFILE ?? "a1";
|
|
6
|
+
const { installFatalExit } = await import("../dist/foundation/terminal-cleanup/index.js");
|
|
7
|
+
const { resolveProductPaths } = await import("../dist/foundation/lifecycle/index.js");
|
|
8
|
+
const { join } = await import("node:path");
|
|
9
|
+
let runningApplication;
|
|
10
|
+
const fatal = profile === "a1" ? installFatalExit({
|
|
11
|
+
directory: join(resolveProductPaths().runtimeDir, "crashes"),
|
|
12
|
+
releaseId: process.env.A1_RELEASE_ID,
|
|
13
|
+
dispose: () => runningApplication?.dispose(),
|
|
14
|
+
}) : undefined;
|
|
5
15
|
// Performance: begin the exact launch graph together while the trace write is pending.
|
|
6
16
|
// Direct owned-module entries avoid evaluating unrelated barrel exports before first paint.
|
|
7
17
|
const modules = Promise.all([
|
|
@@ -32,7 +42,6 @@ assertSinglePiTuiModuleAtLaunch(fileURLToPath(new URL("..", import.meta.url)), m
|
|
|
32
42
|
await startup.markStartupPhase(process.env, "ui-modules-loaded");
|
|
33
43
|
|
|
34
44
|
const sessionSelection = parseSessionSelection(process.argv.slice(2));
|
|
35
|
-
const profile = process.env.A1_LAUNCH_PROFILE ?? "a1";
|
|
36
45
|
Promise.resolve().then(() => {
|
|
37
46
|
if (sessionSelection && profile !== "a1") throw new Error("session selection requires the normal A1 profile");
|
|
38
47
|
return runSelectedInteractiveRuntime(profile, {
|
|
@@ -45,12 +54,15 @@ Promise.resolve().then(() => {
|
|
|
45
54
|
sessionForkPrompt: createConsoleSessionForkPrompt(),
|
|
46
55
|
...(sessionSelection === undefined ? {} : { sessionSelection }),
|
|
47
56
|
});
|
|
57
|
+
runningApplication = application;
|
|
48
58
|
return await runOwnedUi({ application, ...(settings === null ? {} : { settings }) });
|
|
49
59
|
},
|
|
50
60
|
});
|
|
51
61
|
}).then(
|
|
52
|
-
code => { process.exitCode = code; },
|
|
62
|
+
code => { fatal?.remove(); process.exitCode = code; },
|
|
53
63
|
error => {
|
|
64
|
+
if (fatal && error?.name !== "PiSessionSelectionError") { fatal.fail(error); return; }
|
|
65
|
+
fatal?.remove();
|
|
54
66
|
console.error(error instanceof Error ? error.message : String(error));
|
|
55
67
|
process.exitCode = error?.name === "PiSessionSelectionError" ? error.exitCode : 1;
|
|
56
68
|
},
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { OwnedUiImageAttachment } from "./model.js";
|
|
2
|
+
export declare const MAX_PROMPT_IMAGES = 8;
|
|
3
|
+
/** Limit applies to canonical ASCII base64, not decoded image bytes. */
|
|
4
|
+
export declare const MAX_IMAGE_DATA_BYTES: number;
|
|
5
|
+
declare const MESSAGES: {
|
|
6
|
+
readonly "image-size": "Image exceeds the 8 MiB encoded base64 limit. Reduce the image size and paste it again.";
|
|
7
|
+
readonly "image-count": "A prompt supports at most 8 images. Remove an attachment before adding another.";
|
|
8
|
+
readonly "image-data": "Image data is invalid. Paste a valid image again.";
|
|
9
|
+
readonly "image-mime": "Image MIME type is invalid. Paste a supported image again.";
|
|
10
|
+
readonly "image-source-size": "Source image exceeds 20 MiB. Reduce the source size and paste it again.";
|
|
11
|
+
readonly "image-pixels": "Source image exceeds 40 million pixels or a 32768-pixel dimension. Reduce its dimensions and paste again.";
|
|
12
|
+
readonly "image-conversion": "This image cannot be safely resized. Paste a PNG or JPEG screenshot instead.";
|
|
13
|
+
readonly "image-codec": "Image preparation is unavailable. Check the installation and paste again.";
|
|
14
|
+
readonly "image-output": "Image cannot fit the output limits without excessive quality loss. Crop it and paste again.";
|
|
15
|
+
readonly "image-timeout": "Image preparation timed out. Remove the failed image and paste again.";
|
|
16
|
+
readonly "image-busy": "Image preparation is full (8 pending images). Wait or remove an image before pasting again.";
|
|
17
|
+
readonly "image-canceled": "Image preparation was canceled. Paste the image again.";
|
|
18
|
+
readonly "image-pending": "Image is still preparing. Wait before submitting.";
|
|
19
|
+
};
|
|
20
|
+
/** Trusted, payload-free diagnostics for user-correctable attachment failures. */
|
|
21
|
+
export declare class ImageAttachmentError extends TypeError {
|
|
22
|
+
readonly code: keyof typeof MESSAGES;
|
|
23
|
+
constructor(code: keyof typeof MESSAGES);
|
|
24
|
+
}
|
|
25
|
+
/** Checks encoded length before decoding or copying a clipboard payload. */
|
|
26
|
+
export declare function assertImageEncodedSize(data: string): void;
|
|
27
|
+
/** Final command admission also covers restored and deferred non-clipboard inputs. */
|
|
28
|
+
export declare function assertPromptImages(images: readonly OwnedUiImageAttachment[]): void;
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export const MAX_PROMPT_IMAGES = 8;
|
|
2
|
+
/** Limit applies to canonical ASCII base64, not decoded image bytes. */
|
|
3
|
+
export const MAX_IMAGE_DATA_BYTES = 8 * 1024 * 1024;
|
|
4
|
+
const MESSAGES = {
|
|
5
|
+
"image-size": "Image exceeds the 8 MiB encoded base64 limit. Reduce the image size and paste it again.",
|
|
6
|
+
"image-count": "A prompt supports at most 8 images. Remove an attachment before adding another.",
|
|
7
|
+
"image-data": "Image data is invalid. Paste a valid image again.",
|
|
8
|
+
"image-mime": "Image MIME type is invalid. Paste a supported image again.",
|
|
9
|
+
"image-source-size": "Source image exceeds 20 MiB. Reduce the source size and paste it again.",
|
|
10
|
+
"image-pixels": "Source image exceeds 40 million pixels or a 32768-pixel dimension. Reduce its dimensions and paste again.",
|
|
11
|
+
"image-conversion": "This image cannot be safely resized. Paste a PNG or JPEG screenshot instead.",
|
|
12
|
+
"image-codec": "Image preparation is unavailable. Check the installation and paste again.",
|
|
13
|
+
"image-output": "Image cannot fit the output limits without excessive quality loss. Crop it and paste again.",
|
|
14
|
+
"image-timeout": "Image preparation timed out. Remove the failed image and paste again.",
|
|
15
|
+
"image-busy": "Image preparation is full (8 pending images). Wait or remove an image before pasting again.",
|
|
16
|
+
"image-canceled": "Image preparation was canceled. Paste the image again.",
|
|
17
|
+
"image-pending": "Image is still preparing. Wait before submitting.",
|
|
18
|
+
};
|
|
19
|
+
/** Trusted, payload-free diagnostics for user-correctable attachment failures. */
|
|
20
|
+
export class ImageAttachmentError extends TypeError {
|
|
21
|
+
code;
|
|
22
|
+
constructor(code) {
|
|
23
|
+
super(MESSAGES[code]);
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.name = "ImageAttachmentError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Checks encoded length before decoding or copying a clipboard payload. */
|
|
29
|
+
export function assertImageEncodedSize(data) {
|
|
30
|
+
if (typeof data !== "string" || data.length === 0)
|
|
31
|
+
throw new ImageAttachmentError("image-data");
|
|
32
|
+
if (Math.ceil(data.length / 4) * 4 > MAX_IMAGE_DATA_BYTES)
|
|
33
|
+
throw new ImageAttachmentError("image-size");
|
|
34
|
+
}
|
|
35
|
+
const VALIDATED_IMAGES = new WeakSet();
|
|
36
|
+
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
37
|
+
/** Final command admission also covers restored and deferred non-clipboard inputs. */
|
|
38
|
+
export function assertPromptImages(images) {
|
|
39
|
+
if (!Array.isArray(images) || images.length > MAX_PROMPT_IMAGES)
|
|
40
|
+
throw new ImageAttachmentError("image-count");
|
|
41
|
+
for (const image of images) {
|
|
42
|
+
if (!image || image.type !== "image")
|
|
43
|
+
throw new ImageAttachmentError("image-data");
|
|
44
|
+
if (VALIDATED_IMAGES.has(image))
|
|
45
|
+
continue;
|
|
46
|
+
assertImageEncodedSize(image.data);
|
|
47
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(image.data) || image.data.length % 4 !== 0
|
|
48
|
+
|| (image.data.endsWith("==") && (BASE64_ALPHABET.indexOf(image.data.at(-3) ?? "") & 15) !== 0)
|
|
49
|
+
|| (image.data.endsWith("=") && !image.data.endsWith("==") && (BASE64_ALPHABET.indexOf(image.data.at(-2) ?? "") & 3) !== 0)) {
|
|
50
|
+
throw new ImageAttachmentError("image-data");
|
|
51
|
+
}
|
|
52
|
+
if (typeof image.mimeType !== "string" || image.mimeType.length > 256 || !/^image\/[a-z0-9.+-]+$/i.test(image.mimeType)) {
|
|
53
|
+
throw new ImageAttachmentError("image-mime");
|
|
54
|
+
}
|
|
55
|
+
// Security: cache only immutable own data properties, never frozen objects with changing getters.
|
|
56
|
+
if (Object.isFrozen(image) && ["type", "data", "mimeType"].every(key => Object.getOwnPropertyDescriptor(image, key)?.value === image[key])) {
|
|
57
|
+
VALIDATED_IMAGES.add(image);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { OWNED_UI_CONTRACT_VERSION, } from "./model.js";
|
|
2
|
+
import { assertPromptImages } from "./image-attachments.js";
|
|
2
3
|
const MAX_ID_LENGTH = 128;
|
|
3
4
|
const MAX_LABEL_LENGTH = 256;
|
|
4
5
|
const MAX_MESSAGE_LENGTH = 4_096;
|
|
5
6
|
const MAX_TEXT_BYTES = 256 * 1024;
|
|
6
7
|
const MAX_PAYLOAD_BYTES = 64 * 1024;
|
|
7
8
|
const MAX_SESSION_VIEW_BYTES = 1024 * 1024;
|
|
8
|
-
const MAX_IMAGE_DATA_BYTES = 8 * 1024 * 1024;
|
|
9
|
-
const MAX_PROMPT_IMAGES = 8;
|
|
10
9
|
const MAX_BLOCKS = 10_000;
|
|
11
10
|
const MAX_CUSTOMIZATIONS = 1_000;
|
|
12
11
|
const MAX_DIAGNOSTICS = 1_000;
|
|
@@ -57,13 +56,7 @@ export function assertOwnedUiCommand(command) {
|
|
|
57
56
|
case "follow-up":
|
|
58
57
|
assertBoundedText(command.text, "owned-UI prompt text", MAX_TEXT_BYTES);
|
|
59
58
|
if (command.images !== undefined) {
|
|
60
|
-
|
|
61
|
-
for (const image of command.images) {
|
|
62
|
-
if (image.type !== "image")
|
|
63
|
-
throw new TypeError("owned-UI prompt image type is invalid");
|
|
64
|
-
assertBoundedText(image.data, "owned-UI prompt image data", MAX_IMAGE_DATA_BYTES);
|
|
65
|
-
assertBoundedText(image.mimeType, "owned-UI prompt image MIME type", MAX_LABEL_LENGTH);
|
|
66
|
-
}
|
|
59
|
+
assertPromptImages(command.images);
|
|
67
60
|
}
|
|
68
61
|
return;
|
|
69
62
|
case "abort":
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { markStartupPhase } from "../../foundation/startup/index.js";
|
|
2
|
+
import { boundedCleanup } from "../../foundation/terminal-cleanup/index.js";
|
|
2
3
|
export async function runOwnedUi(options) {
|
|
3
4
|
const { application, settings } = options;
|
|
5
|
+
let failed = false;
|
|
6
|
+
let originalFailure;
|
|
4
7
|
try {
|
|
5
8
|
if (settings)
|
|
6
9
|
await settings.load();
|
|
@@ -11,7 +14,19 @@ export async function runOwnedUi(options) {
|
|
|
11
14
|
await application.waitUntilStopped();
|
|
12
15
|
return 0;
|
|
13
16
|
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
failed = true;
|
|
19
|
+
originalFailure = error;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
14
22
|
finally {
|
|
15
|
-
|
|
23
|
+
try {
|
|
24
|
+
await boundedCleanup(() => application.dispose(), 2500);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (failed)
|
|
28
|
+
throw new AggregateError([originalFailure, error], "Owned UI run and cleanup failed", { cause: originalFailure });
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
16
31
|
}
|
|
17
32
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { restoreAfterOwnedExit } from "../terminal-cleanup/index.js";
|
|
2
3
|
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
4
|
import { connect } from "node:net";
|
|
4
5
|
import { platform } from "node:os";
|
|
@@ -234,7 +235,7 @@ async function launchUi(release, environment, sessionArgs) {
|
|
|
234
235
|
windowsHide: false,
|
|
235
236
|
});
|
|
236
237
|
child.once("error", rejectPromise);
|
|
237
|
-
child.once("close", (code, signal) => resolvePromise(
|
|
238
|
+
child.once("close", (code, signal) => resolvePromise(restoreAfterOwnedExit(environment[PRODUCT_IDENTITY.environment.launchProfile] === "a1", code, signal)));
|
|
238
239
|
});
|
|
239
240
|
}
|
|
240
241
|
export function releaseEnvironment(environment, release) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface FatalExitOptions {
|
|
2
|
+
readonly directory: string;
|
|
3
|
+
readonly releaseId?: string;
|
|
4
|
+
readonly restore?: () => void;
|
|
5
|
+
readonly dispose?: () => Promise<unknown> | void;
|
|
6
|
+
readonly timeoutMs?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function writeFatalDiagnostic(directory: string, origin: string, error: unknown, releaseId?: string): Promise<string | null>;
|
|
9
|
+
export declare function installFatalExit(options: FatalExitOptions): {
|
|
10
|
+
fail(error: unknown): void;
|
|
11
|
+
remove(): void;
|
|
12
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { writeSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { boundedCleanup, restoreProcessTerminal } from "./terminal-reset.js";
|
|
6
|
+
export async function writeFatalDiagnostic(directory, origin, error, releaseId) {
|
|
7
|
+
try {
|
|
8
|
+
// Security: only known classifications and code locations are retained. Error messages,
|
|
9
|
+
// function names, arbitrary paths, and request objects are never persisted.
|
|
10
|
+
const cleanupFailed = error instanceof AggregateError && error.cause instanceof Error;
|
|
11
|
+
const primary = cleanupFailed ? error.cause : error;
|
|
12
|
+
const classification = primary instanceof TypeError ? "TypeError" : primary instanceof RangeError ? "RangeError" : "Error";
|
|
13
|
+
const stack = primary instanceof Error && typeof primary.stack === "string" ? primary.stack.slice(0, 32768) : "";
|
|
14
|
+
const locations = stack.split("\n").slice(1).flatMap(line => {
|
|
15
|
+
const match = /\/(?:dist|src)\/([a-zA-Z0-9_./-]+\.[cm]?[jt]s):(\d+):(\d+)\)?$/.exec(line.replaceAll("\\", "/"));
|
|
16
|
+
return match ? [{ file: match[1].slice(0, 160), line: Number(match[2]), column: Number(match[3]) }] : [];
|
|
17
|
+
}).slice(0, 16);
|
|
18
|
+
const record = {
|
|
19
|
+
timestamp: new Date().toISOString(),
|
|
20
|
+
origin: ["uncaughtException", "unhandledRejection", "entry"].includes(origin) ? origin : "entry",
|
|
21
|
+
classification, cleanupFailed, locations, node: process.version, platform: process.platform,
|
|
22
|
+
releaseId: releaseId && /^[a-zA-Z0-9._-]{1,128}$/.test(releaseId) ? releaseId : "unknown",
|
|
23
|
+
};
|
|
24
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
25
|
+
const name = `fatal-${Date.now()}-${randomUUID()}.json`;
|
|
26
|
+
const path = join(directory, name);
|
|
27
|
+
const text = JSON.stringify(record, null, 2);
|
|
28
|
+
if (Buffer.byteLength(text) > 16 * 1024)
|
|
29
|
+
return null;
|
|
30
|
+
await writeFile(path, text, { flag: "wx", mode: 0o600 });
|
|
31
|
+
const names = (await readdir(directory)).filter(file => /^fatal-\d+-[a-f0-9-]+\.json$/.test(file)).sort().reverse();
|
|
32
|
+
await Promise.all(names.slice(10).map(file => unlink(join(directory, file)).catch(() => undefined)));
|
|
33
|
+
return path;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function installFatalExit(options) {
|
|
40
|
+
let failing = false;
|
|
41
|
+
const restore = () => { try {
|
|
42
|
+
(options.restore ?? restoreProcessTerminal)();
|
|
43
|
+
}
|
|
44
|
+
catch { } };
|
|
45
|
+
const terminate = (origin, error) => {
|
|
46
|
+
if (failing)
|
|
47
|
+
return;
|
|
48
|
+
failing = true;
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
// Security: stop accepting input immediately; fatal errors never resume normal work.
|
|
51
|
+
try {
|
|
52
|
+
process.stdin.pause();
|
|
53
|
+
process.stdin.removeAllListeners("data");
|
|
54
|
+
}
|
|
55
|
+
catch { }
|
|
56
|
+
restore();
|
|
57
|
+
let record = null;
|
|
58
|
+
let finished = false;
|
|
59
|
+
const finish = () => {
|
|
60
|
+
if (finished)
|
|
61
|
+
return;
|
|
62
|
+
finished = true;
|
|
63
|
+
restore();
|
|
64
|
+
try {
|
|
65
|
+
const location = record?.replace(/[\x00-\x1f\x7f]/g, "").slice(0, 512);
|
|
66
|
+
writeSync(2, `A1 stopped after an unexpected error.${location ? ` Diagnostic: ${location}` : " Diagnostic storage unavailable."}\n`);
|
|
67
|
+
}
|
|
68
|
+
catch { }
|
|
69
|
+
process.exit(1);
|
|
70
|
+
};
|
|
71
|
+
const deadline = setTimeout(finish, options.timeoutMs ?? 1000);
|
|
72
|
+
void Promise.all([
|
|
73
|
+
writeFatalDiagnostic(options.directory, origin, error, options.releaseId).then(path => { record = path; }),
|
|
74
|
+
boundedCleanup(() => options.dispose?.(), options.timeoutMs ?? 1000).catch(() => undefined),
|
|
75
|
+
]).then(() => { clearTimeout(deadline); finish(); }, finish);
|
|
76
|
+
};
|
|
77
|
+
const exception = (error) => terminate("uncaughtException", error);
|
|
78
|
+
const rejection = (error) => terminate("unhandledRejection", error);
|
|
79
|
+
process.on("uncaughtException", exception);
|
|
80
|
+
process.on("unhandledRejection", rejection);
|
|
81
|
+
return {
|
|
82
|
+
fail: error => terminate("entry", error),
|
|
83
|
+
remove() { process.off("uncaughtException", exception); process.off("unhandledRejection", rejection); },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const EMERGENCY_TERMINAL_RESET: string;
|
|
2
|
+
export declare function restoreProcessTerminal(): void;
|
|
3
|
+
export declare function restoreAfterOwnedExit(ownsTerminal: boolean, code: number | null, signal: string | null, restore?: () => void): number;
|
|
4
|
+
export declare function boundedCleanup(cleanup: () => Promise<unknown> | void, timeoutMs?: number): Promise<void>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { writeSync } from "node:fs";
|
|
2
|
+
// Protocol: reset modes on both screens; setting keyboard flags to zero is idempotent,
|
|
3
|
+
// unlike repeatedly popping a keyboard-protocol stack owned by a parent application.
|
|
4
|
+
export const EMERGENCY_TERMINAL_RESET = "\x1b[?2026l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1004l\x1b[?1006l"
|
|
5
|
+
+ "\x1b[?2004l\x1b[=0u\x1b[>4;0m\x1b]8;;\x1b\\\x1b[0m\x1b[r\x1b[?7h\x1b[?25h"
|
|
6
|
+
+ "\x1b[?1049l\x1b[r\x1b[?7h\x1b[?25h\x1b]9;4;0\x07";
|
|
7
|
+
export function restoreProcessTerminal() {
|
|
8
|
+
try {
|
|
9
|
+
if (process.stdout.isTTY)
|
|
10
|
+
writeSync(process.stdout.fd, EMERGENCY_TERMINAL_RESET);
|
|
11
|
+
}
|
|
12
|
+
catch { }
|
|
13
|
+
try {
|
|
14
|
+
if (process.stdin.isTTY)
|
|
15
|
+
process.stdin.setRawMode(false);
|
|
16
|
+
}
|
|
17
|
+
catch { }
|
|
18
|
+
}
|
|
19
|
+
export function restoreAfterOwnedExit(ownsTerminal, code, signal, restore = restoreProcessTerminal) {
|
|
20
|
+
const outcome = code ?? (signal ? 1 : 0);
|
|
21
|
+
if (ownsTerminal && outcome !== 0) {
|
|
22
|
+
try {
|
|
23
|
+
restore();
|
|
24
|
+
}
|
|
25
|
+
catch { }
|
|
26
|
+
}
|
|
27
|
+
return outcome;
|
|
28
|
+
}
|
|
29
|
+
export async function boundedCleanup(cleanup, timeoutMs = 1000) {
|
|
30
|
+
let timer;
|
|
31
|
+
try {
|
|
32
|
+
await Promise.race([
|
|
33
|
+
Promise.resolve().then(cleanup),
|
|
34
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Cleanup deadline exceeded")), timeoutMs); }),
|
|
35
|
+
]);
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -15,6 +15,7 @@ export interface OwnedEditorUxInterceptor {
|
|
|
15
15
|
hasSelection?(): boolean;
|
|
16
16
|
ownsPointer?(): boolean;
|
|
17
17
|
pasteClipboard?(): boolean;
|
|
18
|
+
cancelPendingPastes?(): void;
|
|
18
19
|
}
|
|
19
20
|
export interface OwnedEditorPointerEvent {
|
|
20
21
|
readonly kind: "press" | "motion" | "release";
|
|
@@ -36,6 +37,7 @@ export declare class OwnedEditorUxInterception {
|
|
|
36
37
|
handleInput(data: string): void;
|
|
37
38
|
render(width: number): string[];
|
|
38
39
|
reset(): void;
|
|
40
|
+
cancelPendingPastes(): void;
|
|
39
41
|
handlePointer(event: OwnedEditorPointerEvent): boolean;
|
|
40
42
|
hasSelection(): boolean;
|
|
41
43
|
ownsPointer(): boolean;
|
|
@@ -44,6 +46,10 @@ export declare class OwnedEditorUxInterception {
|
|
|
44
46
|
export interface PromptSelectionUxOptions {
|
|
45
47
|
readonly copyText: (text: string) => void;
|
|
46
48
|
readonly readClipboardContent: () => Promise<PiShellClipboardContent | null>;
|
|
49
|
+
readonly beginClipboardPaste?: () => {
|
|
50
|
+
readonly marker: string;
|
|
51
|
+
readonly result: Promise<string>;
|
|
52
|
+
};
|
|
47
53
|
readonly transformPastedContent: (content: PiShellClipboardContent) => string;
|
|
48
54
|
readonly atomicRanges: (line: string) => readonly PiShellEditorTextRange[];
|
|
49
55
|
readonly expandCopiedText: (text: string) => string;
|
|
@@ -31,6 +31,10 @@ export class OwnedEditorUxInterception {
|
|
|
31
31
|
for (const interceptor of this.interceptors)
|
|
32
32
|
interceptor.reset();
|
|
33
33
|
}
|
|
34
|
+
cancelPendingPastes() {
|
|
35
|
+
for (const interceptor of this.interceptors)
|
|
36
|
+
interceptor.cancelPendingPastes?.();
|
|
37
|
+
}
|
|
34
38
|
handlePointer(event) {
|
|
35
39
|
return this.interceptors.some(interceptor => interceptor.handlePointer?.(event) === true);
|
|
36
40
|
}
|
|
@@ -60,6 +64,7 @@ class PromptSelectionInterceptor {
|
|
|
60
64
|
#lastClick;
|
|
61
65
|
#redoStack = [];
|
|
62
66
|
#selectionRevision = 0;
|
|
67
|
+
#pasteGeneration = 0;
|
|
63
68
|
#wordDirection;
|
|
64
69
|
#geometry;
|
|
65
70
|
constructor(editor, keybindings, options) {
|
|
@@ -245,6 +250,7 @@ class PromptSelectionInterceptor {
|
|
|
245
250
|
this.#redoStack = [];
|
|
246
251
|
this.#selectionRevision += 1;
|
|
247
252
|
}
|
|
253
|
+
cancelPendingPastes() { this.#pasteGeneration += 1; }
|
|
248
254
|
hasSelection() {
|
|
249
255
|
return this.#activeRange() !== undefined;
|
|
250
256
|
}
|
|
@@ -404,6 +410,20 @@ class PromptSelectionInterceptor {
|
|
|
404
410
|
this.#requestRender();
|
|
405
411
|
}
|
|
406
412
|
#pasteFromClipboard() {
|
|
413
|
+
if (this.options.beginClipboardPaste !== undefined) {
|
|
414
|
+
const generation = this.#pasteGeneration;
|
|
415
|
+
const restore = this.#orderedSelection() === undefined ? "" : this.#selectedText();
|
|
416
|
+
const paste = this.options.beginClipboardPaste();
|
|
417
|
+
if (this.#orderedSelection() !== undefined)
|
|
418
|
+
this.#replaceSelection(paste.marker);
|
|
419
|
+
else
|
|
420
|
+
this.editor.insertTextAtCursor(paste.marker);
|
|
421
|
+
this.#requestRender();
|
|
422
|
+
const replace = (text) => { if (generation === this.#pasteGeneration)
|
|
423
|
+
this.#replacePasteMarker(paste.marker, text || restore); };
|
|
424
|
+
void paste.result.then(replace).catch(() => replace(restore));
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
407
427
|
const revision = this.#selectionRevision;
|
|
408
428
|
const selection = this.#orderedSelection();
|
|
409
429
|
const atomicFocus = this.#atomicFocus();
|
|
@@ -428,6 +448,44 @@ class PromptSelectionInterceptor {
|
|
|
428
448
|
this.#requestRender();
|
|
429
449
|
}).catch(() => { });
|
|
430
450
|
}
|
|
451
|
+
#replacePasteMarker(marker, replacement) {
|
|
452
|
+
const current = this.editor.getText();
|
|
453
|
+
const from = current.indexOf(marker);
|
|
454
|
+
if (from < 0)
|
|
455
|
+
return;
|
|
456
|
+
const to = from + marker.length;
|
|
457
|
+
const text = normalizeInsertedText(replacement);
|
|
458
|
+
const lines = editorState(this.editor).lines;
|
|
459
|
+
const remap = (position) => {
|
|
460
|
+
const offset = positionOffset(lines, position);
|
|
461
|
+
return offset <= from ? offset : offset >= to ? offset + text.length - marker.length : from + text.length;
|
|
462
|
+
};
|
|
463
|
+
const cursor = remap(this.#cursor());
|
|
464
|
+
const selection = this.#selection === undefined ? undefined : { anchor: remap(this.#selection.anchor), head: remap(this.#selection.head) };
|
|
465
|
+
const next = current.slice(0, from) + text + current.slice(to);
|
|
466
|
+
// Compatibility: completing a paste updates its provisional undo snapshots, not the user's undo history.
|
|
467
|
+
editorState(this.editor).lines = next.split("\n");
|
|
468
|
+
const undo = Reflect.get(this.editor, "undoStack");
|
|
469
|
+
const snapshots = typeof undo === "object" && undo !== null ? Reflect.get(undo, "stack") : undefined;
|
|
470
|
+
if (Array.isArray(snapshots))
|
|
471
|
+
for (const snapshot of snapshots) {
|
|
472
|
+
const state = typeof snapshot === "object" && snapshot !== null ? Reflect.get(snapshot, "state") : undefined;
|
|
473
|
+
if (isEditorState(state))
|
|
474
|
+
replaceSnapshotMarker(state, marker, text);
|
|
475
|
+
}
|
|
476
|
+
for (const snapshot of this.#redoStack) {
|
|
477
|
+
const state = { lines: snapshot.text.split("\n"), cursorLine: snapshot.cursor.line, cursorCol: snapshot.cursor.col };
|
|
478
|
+
replaceSnapshotMarker(state, marker, text);
|
|
479
|
+
snapshot.text = state.lines.join("\n");
|
|
480
|
+
snapshot.cursor = { line: state.cursorLine, col: state.cursorCol };
|
|
481
|
+
}
|
|
482
|
+
this.#setCursor(positionAtOffset(next, cursor));
|
|
483
|
+
this.editor.onChange?.(this.editor.getText());
|
|
484
|
+
this.editor.invalidate();
|
|
485
|
+
if (selection !== undefined)
|
|
486
|
+
this.#selection = { anchor: positionAtOffset(next, selection.anchor), head: positionAtOffset(next, selection.head) };
|
|
487
|
+
this.#requestRender();
|
|
488
|
+
}
|
|
431
489
|
#replaceSelection(text) {
|
|
432
490
|
const selection = this.#orderedSelection();
|
|
433
491
|
if (selection === undefined) {
|
|
@@ -825,6 +883,19 @@ function positionAtOffset(text, requestedOffset) {
|
|
|
825
883
|
const before = text.slice(0, offset).split("\n");
|
|
826
884
|
return { line: before.length - 1, col: (before.at(-1) ?? "").length };
|
|
827
885
|
}
|
|
886
|
+
function replaceSnapshotMarker(state, marker, replacement) {
|
|
887
|
+
const text = state.lines.join("\n");
|
|
888
|
+
const from = text.indexOf(marker);
|
|
889
|
+
if (from < 0)
|
|
890
|
+
return;
|
|
891
|
+
const cursor = positionOffset(state.lines, { line: state.cursorLine, col: state.cursorCol });
|
|
892
|
+
const offset = cursor <= from ? cursor : cursor >= from + marker.length ? cursor + replacement.length - marker.length : from + replacement.length;
|
|
893
|
+
const next = text.slice(0, from) + replacement + text.slice(from + marker.length);
|
|
894
|
+
const position = positionAtOffset(next, offset);
|
|
895
|
+
state.lines = next.split("\n");
|
|
896
|
+
state.cursorLine = position.line;
|
|
897
|
+
state.cursorCol = position.col;
|
|
898
|
+
}
|
|
828
899
|
function normalizeInsertedText(text) {
|
|
829
900
|
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ");
|
|
830
901
|
}
|
|
@@ -54,6 +54,7 @@ export function createPiShellEditor(options) {
|
|
|
54
54
|
createPromptSelectionInterceptor(editor, keybindings, {
|
|
55
55
|
copyText: options.onCopyText ?? (() => { }),
|
|
56
56
|
readClipboardContent: options.readClipboardContent ?? (async () => null),
|
|
57
|
+
...(options.beginClipboardPaste === undefined ? {} : { beginClipboardPaste: options.beginClipboardPaste }),
|
|
57
58
|
transformPastedContent: options.transformPastedContent ?? (content => content.kind === "text" ? content.text : ""),
|
|
58
59
|
atomicRanges: options.editorAtomicRanges ?? (() => []),
|
|
59
60
|
expandCopiedText: options.expandCopiedEditorText ?? (text => text),
|
|
@@ -202,6 +203,7 @@ export function createPiShellEditor(options) {
|
|
|
202
203
|
ownsPointer: () => editorUx?.ownsPointer() ?? false,
|
|
203
204
|
handlePointer: event => editorUx?.handlePointer(event) ?? false,
|
|
204
205
|
pasteClipboard: () => editorUx?.pasteClipboard() ?? false,
|
|
206
|
+
cancelPendingPastes: () => editorUx?.cancelPendingPastes(),
|
|
205
207
|
};
|
|
206
208
|
}
|
|
207
209
|
function autocompleteCommand(command, addition) {
|
|
@@ -47,6 +47,7 @@ export interface PiShellEditorPort extends PiShellComponentPort {
|
|
|
47
47
|
ownsPointer(): boolean;
|
|
48
48
|
handlePointer(event: PiShellEditorPointerEvent): boolean;
|
|
49
49
|
pasteClipboard(): boolean;
|
|
50
|
+
cancelPendingPastes?(): void;
|
|
50
51
|
}
|
|
51
52
|
export interface PiShellAutocompleteCommand {
|
|
52
53
|
readonly name: string;
|
|
@@ -135,7 +136,11 @@ export interface PiShellEditorOptions {
|
|
|
135
136
|
readonly onDequeue?: (() => void) | undefined;
|
|
136
137
|
readonly onPromptSuggestionAccepted?: (text: string) => void;
|
|
137
138
|
readonly onCopyText?: (text: string) => void;
|
|
138
|
-
readonly readClipboardContent?: () => Promise<PiShellClipboardContent | null>;
|
|
139
|
+
readonly readClipboardContent?: (signal?: AbortSignal) => Promise<PiShellClipboardContent | null>;
|
|
140
|
+
readonly beginClipboardPaste?: () => {
|
|
141
|
+
readonly marker: string;
|
|
142
|
+
readonly result: Promise<string>;
|
|
143
|
+
};
|
|
139
144
|
readonly transformPastedContent?: (content: PiShellClipboardContent) => string;
|
|
140
145
|
readonly editorAtomicRanges?: (line: string) => readonly PiShellEditorTextRange[];
|
|
141
146
|
readonly expandCopiedEditorText?: (text: string) => string;
|
|
@@ -7,5 +7,5 @@ export interface ClipboardImageData {
|
|
|
7
7
|
* Clipboard adapters are untrusted at this boundary, so reject representations
|
|
8
8
|
* that Node's permissive base64 decoder would otherwise partially accept.
|
|
9
9
|
*/
|
|
10
|
-
export declare function canonicalizeClipboardImage(image: ClipboardImageData): ClipboardImageData | null;
|
|
11
|
-
export declare function canonicalizeStandardBase64(value: string): string | null;
|
|
10
|
+
export declare function canonicalizeClipboardImage(image: ClipboardImageData, source?: boolean): ClipboardImageData | null;
|
|
11
|
+
export declare function canonicalizeStandardBase64(value: string, source?: boolean): string | null;
|
|
@@ -1,15 +1,22 @@
|
|
|
1
|
+
import { ImageAttachmentError, MAX_IMAGE_DATA_BYTES } from "../../../contracts/owned-ui/index.js";
|
|
2
|
+
import { MAX_SOURCE_IMAGE_BYTES } from "./image-source.js";
|
|
1
3
|
const STANDARD_BASE64 = /^[A-Za-z0-9+/]*={0,2}$/u;
|
|
2
4
|
/**
|
|
3
5
|
* Converts valid padded or unpadded standard base64 into its unique padded form.
|
|
4
6
|
* Clipboard adapters are untrusted at this boundary, so reject representations
|
|
5
7
|
* that Node's permissive base64 decoder would otherwise partially accept.
|
|
6
8
|
*/
|
|
7
|
-
export function canonicalizeClipboardImage(image) {
|
|
8
|
-
const data = canonicalizeStandardBase64(image.data);
|
|
9
|
+
export function canonicalizeClipboardImage(image, source = false) {
|
|
10
|
+
const data = canonicalizeStandardBase64(image.data, source);
|
|
9
11
|
return data === null ? null : { data, mimeType: image.mimeType };
|
|
10
12
|
}
|
|
11
|
-
export function canonicalizeStandardBase64(value) {
|
|
12
|
-
if (value.length === 0
|
|
13
|
+
export function canonicalizeStandardBase64(value, source = false) {
|
|
14
|
+
if (typeof value !== "string" || value.length === 0)
|
|
15
|
+
return null;
|
|
16
|
+
const maximum = source ? Math.ceil(MAX_SOURCE_IMAGE_BYTES / 3) * 4 : MAX_IMAGE_DATA_BYTES;
|
|
17
|
+
if (Math.ceil(value.length / 4) * 4 > maximum)
|
|
18
|
+
throw new ImageAttachmentError(source ? "image-source-size" : "image-size");
|
|
19
|
+
if (!STANDARD_BASE64.test(value))
|
|
13
20
|
return null;
|
|
14
21
|
const paddingStart = value.indexOf("=");
|
|
15
22
|
const core = paddingStart < 0 ? value : value.slice(0, paddingStart);
|
|
@@ -22,6 +29,8 @@ export function canonicalizeStandardBase64(value) {
|
|
|
22
29
|
return null;
|
|
23
30
|
const padded = `${core}${"=".repeat(requiredPadding)}`;
|
|
24
31
|
const decoded = Buffer.from(padded, "base64");
|
|
32
|
+
if (source && decoded.length > MAX_SOURCE_IMAGE_BYTES)
|
|
33
|
+
throw new ImageAttachmentError("image-source-size");
|
|
25
34
|
if (decoded.length === 0)
|
|
26
35
|
return null;
|
|
27
36
|
const canonical = decoded.toString("base64");
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PiShellClipboardContent } from "../components/index.js";
|
|
2
|
+
import type { ImagePreparationLimits, PreparedImage } from "./image-preparation.js";
|
|
3
|
+
import type { ImageWorkerRequest } from "./image-worker.js";
|
|
4
|
+
export type PreparedClipboardContent = {
|
|
5
|
+
readonly kind: "text";
|
|
6
|
+
readonly text: string;
|
|
7
|
+
} | ({
|
|
8
|
+
readonly kind: "image";
|
|
9
|
+
} & PreparedImage) | null;
|
|
10
|
+
export interface ImagePasteJob {
|
|
11
|
+
readonly result: Promise<PreparedClipboardContent>;
|
|
12
|
+
cancel(): void;
|
|
13
|
+
}
|
|
14
|
+
/** Owns bounded paste acquisition and one off-thread conversion, canceled on session disposal. */
|
|
15
|
+
export declare class ImagePreparationClient {
|
|
16
|
+
#private;
|
|
17
|
+
start(read?: (signal: AbortSignal) => Promise<PiShellClipboardContent | null>, limits?: ImagePreparationLimits): ImagePasteJob;
|
|
18
|
+
dispose(): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
export declare function runImageWorker<T>(request: ImageWorkerRequest, signal: AbortSignal): Promise<T>;
|