@khorsheed/dsh-file-preview 0.4.1 → 0.4.2
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/CHANGELOG.md +7 -0
- package/README.en.md +24 -2
- package/README.i18n.yaml +2 -2
- package/README.md +24 -2
- package/lib/client.js +15165 -682
- package/lib/index.js +581 -280
- package/lib/typert.host.js +437 -84
- package/lib/typert.remote-client.d.ts +10 -2
- package/lib/typert.remote-client.js +174 -78
- package/lib/types/bash-writes.d.ts +19 -1
- package/lib/types/bash-writes.js +56 -2
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/client/locales.js +6 -2
- package/lib/types/client/open-in-app.js +7 -0
- package/lib/types/index.d.ts +8 -18
- package/lib/types/index.js +50 -233
- package/lib/types/read-surface.d.ts +269 -0
- package/lib/types/read-surface.js +413 -0
- package/package.json +17 -19
- package/lib/invariant.js +0 -25
- package/lib/types/invariant.d.ts +0 -16
- package/lib/types/invariant.js +0 -23
package/lib/index.js
CHANGED
|
@@ -265,6 +265,48 @@ var BashWriteCollector = class {
|
|
|
265
265
|
captured(sessionId) {
|
|
266
266
|
return this.#captured.get(sessionId);
|
|
267
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* Rebuild one session's captures from its own event snapshot — the
|
|
270
|
+
* snapshot-driven twin of the live feed's replay, for a caller that holds
|
|
271
|
+
* the session's events rather than a composition's event feed. The same
|
|
272
|
+
* extraction, expansion, and stat-verification pipeline runs in event
|
|
273
|
+
* order, every verification awaited, so the result mirrors what the live
|
|
274
|
+
* feed would have captured from the same events. Nothing is stored in the
|
|
275
|
+
* live registry: pending candidates and verified captures are local to the
|
|
276
|
+
* call.
|
|
277
|
+
* @param cwd - the session's cwd for relative write targets, or undefined.
|
|
278
|
+
* @param events - the session's events in ascending seq order.
|
|
279
|
+
* @returns the verified captures (undefined when none survived, mirroring
|
|
280
|
+
* `captured`).
|
|
281
|
+
*/
|
|
282
|
+
async replaySnapshot(cwd, events) {
|
|
283
|
+
const pending = /* @__PURE__ */ new Map();
|
|
284
|
+
const captured = /* @__PURE__ */ new Map();
|
|
285
|
+
for (const event of events) {
|
|
286
|
+
if (event.type === "tool/call") {
|
|
287
|
+
if (event.data.name !== "bash") continue;
|
|
288
|
+
const command = parseArguments(event.data.arguments).command;
|
|
289
|
+
if (typeof command !== "string" || command.length === 0) continue;
|
|
290
|
+
const candidates = extractWriteTargets(command).map((token) => expandShellPath(token, this.#env, this.#home, cwd)).filter((path) => path !== null);
|
|
291
|
+
if (candidates.length === 0) continue;
|
|
292
|
+
pending.set(String(event.data.callId), {
|
|
293
|
+
turn: event.data.turn,
|
|
294
|
+
step: event.data.step,
|
|
295
|
+
seq: event.seq,
|
|
296
|
+
candidates
|
|
297
|
+
});
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (event.type === "tool/result") {
|
|
301
|
+
const callId = String(event.data.message?.source?.callId ?? "");
|
|
302
|
+
const write = pending.get(callId);
|
|
303
|
+
if (write === void 0) continue;
|
|
304
|
+
pending.delete(callId);
|
|
305
|
+
await this.#verifyInto(captured, write);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return captured.size === 0 ? void 0 : captured;
|
|
309
|
+
}
|
|
268
310
|
/** Rebuild one session's registry from its own history (host restart). */
|
|
269
311
|
#replay(session) {
|
|
270
312
|
this.#pending.set(session.id, /* @__PURE__ */ new Map());
|
|
@@ -301,6 +343,11 @@ var BashWriteCollector = class {
|
|
|
301
343
|
/** stat-verify the pending candidates and store the survivors. */
|
|
302
344
|
async #verify(session, pending) {
|
|
303
345
|
const captured = this.#captured.get(session.id) ?? /* @__PURE__ */ new Map();
|
|
346
|
+
await this.#verifyInto(captured, pending);
|
|
347
|
+
if (captured.size > 0) this.#captured.set(session.id, captured);
|
|
348
|
+
}
|
|
349
|
+
/** stat-verify the pending candidates into the given capture map. */
|
|
350
|
+
async #verifyInto(captured, pending) {
|
|
304
351
|
for (const candidate of pending.candidates) {
|
|
305
352
|
if (captured.size >= this.#maxFiles || captured.has(candidate)) continue;
|
|
306
353
|
let ok = false;
|
|
@@ -316,7 +363,6 @@ var BashWriteCollector = class {
|
|
|
316
363
|
seq: pending.seq
|
|
317
364
|
});
|
|
318
365
|
}
|
|
319
|
-
if (captured.size > 0) this.#captured.set(session.id, captured);
|
|
320
366
|
}
|
|
321
367
|
};
|
|
322
368
|
//#endregion
|
|
@@ -725,6 +771,494 @@ async function openExternalNative(path, appId, signal, internals = {}) {
|
|
|
725
771
|
], signal, "visible");
|
|
726
772
|
}
|
|
727
773
|
//#endregion
|
|
774
|
+
//#region lib/types/read-surface.js
|
|
775
|
+
/**
|
|
776
|
+
* The read core and the read-surface factory. The `filePreview` Remote face
|
|
777
|
+
* resolves its `agent` parameter through the host's unified lookup — a lookup
|
|
778
|
+
* that can wake an offline session — so a trusted server-side caller that
|
|
779
|
+
* already holds an authorized session source (its events, its cwd anchor, an
|
|
780
|
+
* fs face) must not reach the data through that wire. This module is the seam:
|
|
781
|
+
* the Remote methods and {@link createFilePreviewReadSurface} run the SAME
|
|
782
|
+
* core functions, so the surface's answers are byte-identical to the Remote
|
|
783
|
+
* face's for the same session by construction.
|
|
784
|
+
*
|
|
785
|
+
* Authorization lives entirely with the caller: the surface reads whatever the
|
|
786
|
+
* injected event reader, anchor, and fs face reach, and performs no admission
|
|
787
|
+
* check of its own. The surface never constructs, resolves, or wakes a session
|
|
788
|
+
* runtime object — it derives everything from the injected source plus pure
|
|
789
|
+
* folds, keeps at most watermark-keyed caches scoped to the returned instance
|
|
790
|
+
* ({@link FilePreviewReadSurface.dispose} drops them), and starts no
|
|
791
|
+
* background work.
|
|
792
|
+
*
|
|
793
|
+
* @module @khorsheed/dsh-file-preview
|
|
794
|
+
*/
|
|
795
|
+
/** Extensions treated as binary without reading (their text decode is meaningless). */
|
|
796
|
+
const BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
797
|
+
".pdf",
|
|
798
|
+
".zip",
|
|
799
|
+
".gz",
|
|
800
|
+
".tar",
|
|
801
|
+
".7z",
|
|
802
|
+
".zstd",
|
|
803
|
+
".br",
|
|
804
|
+
".woff",
|
|
805
|
+
".woff2",
|
|
806
|
+
".ttf",
|
|
807
|
+
".otf",
|
|
808
|
+
".eot",
|
|
809
|
+
".wasm",
|
|
810
|
+
".node",
|
|
811
|
+
".dylib",
|
|
812
|
+
".so",
|
|
813
|
+
".dll",
|
|
814
|
+
".exe",
|
|
815
|
+
".kext",
|
|
816
|
+
".class",
|
|
817
|
+
".jar",
|
|
818
|
+
".pyc",
|
|
819
|
+
".sqlite",
|
|
820
|
+
".db",
|
|
821
|
+
".DS_Store",
|
|
822
|
+
".a",
|
|
823
|
+
".o"
|
|
824
|
+
]);
|
|
825
|
+
/** Image extensions served to the browser through an image address. */
|
|
826
|
+
const IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
827
|
+
".png",
|
|
828
|
+
".jpg",
|
|
829
|
+
".jpeg",
|
|
830
|
+
".gif",
|
|
831
|
+
".webp",
|
|
832
|
+
".avif",
|
|
833
|
+
".bmp",
|
|
834
|
+
".ico",
|
|
835
|
+
".svg"
|
|
836
|
+
]);
|
|
837
|
+
/** MIME type per image extension for a byte read's Content-Type answer. */
|
|
838
|
+
const IMAGE_MIME = {
|
|
839
|
+
".png": "image/png",
|
|
840
|
+
".jpg": "image/jpeg",
|
|
841
|
+
".jpeg": "image/jpeg",
|
|
842
|
+
".gif": "image/gif",
|
|
843
|
+
".webp": "image/webp",
|
|
844
|
+
".avif": "image/avif",
|
|
845
|
+
".bmp": "image/bmp",
|
|
846
|
+
".ico": "image/x-icon",
|
|
847
|
+
".svg": "image/svg+xml"
|
|
848
|
+
};
|
|
849
|
+
/** HTML extensions served through the sandboxed render channel. */
|
|
850
|
+
const HTML_EXTENSIONS = /* @__PURE__ */ new Set([".html", ".htm"]);
|
|
851
|
+
/** NUL bytes in decoded text mark a file as binary despite a successful decode. */
|
|
852
|
+
function hasNulByte(content) {
|
|
853
|
+
return content.slice(0, 4096).includes("\0");
|
|
854
|
+
}
|
|
855
|
+
/** Shared non-fatal UTF-8 decoder for the bounded byte-window read: invalid
|
|
856
|
+
* sequences become U+FFFD (a cut multi-byte character at the window edge
|
|
857
|
+
* included) instead of failing the read. */
|
|
858
|
+
const utf8Decoder = new TextDecoder();
|
|
859
|
+
/** Detect a binary file from its display path extension. */
|
|
860
|
+
function isBinaryPath(path) {
|
|
861
|
+
const dot = path.lastIndexOf(".");
|
|
862
|
+
return dot >= 0 && BINARY_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
863
|
+
}
|
|
864
|
+
/** Detect an image from its display path extension. */
|
|
865
|
+
function isImagePath(path) {
|
|
866
|
+
const dot = path.lastIndexOf(".");
|
|
867
|
+
return dot >= 0 && IMAGE_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
868
|
+
}
|
|
869
|
+
/** Detect an HTML document from its display path extension. */
|
|
870
|
+
function isHtmlPath(path) {
|
|
871
|
+
const dot = path.lastIndexOf(".");
|
|
872
|
+
return dot >= 0 && HTML_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
873
|
+
}
|
|
874
|
+
/** Best-effort scripted-HTML detection: any `<script>` tag, inline event
|
|
875
|
+
* handler (`on*="…"`), or `javascript:` URL marks the document as scripted.
|
|
876
|
+
* Only a hint for default-mode selection and warning — never a trust
|
|
877
|
+
* decision; the sandbox and the Tier1 CSP are the real boundary. The scan
|
|
878
|
+
* runs on the read content as delivered (a truncated read may miss markers
|
|
879
|
+
* in the tail; acceptable for a hint). */
|
|
880
|
+
function isScriptedHtml(content) {
|
|
881
|
+
return /<\s*script[\s>]/i.test(content) || /\son[a-z]+\s*=/i.test(content) || /javascript:/i.test(content);
|
|
882
|
+
}
|
|
883
|
+
/** Stable error text for a failed read, without leaking backend internals. */
|
|
884
|
+
function readErrorMessage(error) {
|
|
885
|
+
return error instanceof Error ? error.message : String(error);
|
|
886
|
+
}
|
|
887
|
+
/** The fold cache watermark: seq of the last event (`-1` for an empty log). */
|
|
888
|
+
function eventsWatermark(events) {
|
|
889
|
+
return events.length === 0 ? -1 : events[events.length - 1].seq;
|
|
890
|
+
}
|
|
891
|
+
/** The resolve options for one anchored probe, built field-by-field so an
|
|
892
|
+
* absent anchor or signal never materializes as an explicit `undefined`
|
|
893
|
+
* (exactOptionalPropertyTypes). */
|
|
894
|
+
function resolveOptions(cwd, signal) {
|
|
895
|
+
const options = {};
|
|
896
|
+
if (cwd !== void 0) options.cwd = cwd;
|
|
897
|
+
if (signal !== void 0) options.signal = signal;
|
|
898
|
+
return options;
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Resolve the recorded display paths against the anchor and keep only those
|
|
902
|
+
* that currently exist as a regular file. The products surfaces fold the
|
|
903
|
+
* session log (which is history, not disk state): a path the log wrote may
|
|
904
|
+
* have been cleaned up since, and such a path is not a product to list.
|
|
905
|
+
* Existence is checked fresh on every call (never cached), so a deletion that
|
|
906
|
+
* lands after a fold is visible on the next fetch. Unresolvable/unstat-able
|
|
907
|
+
* paths are treated as absent.
|
|
908
|
+
* @param fs - the fs face the probes run through.
|
|
909
|
+
* @param cwd - the anchor relative paths resolve against, or undefined.
|
|
910
|
+
* @param paths - the display paths to probe.
|
|
911
|
+
* @returns the subset that resolves to an existing regular file.
|
|
912
|
+
*/
|
|
913
|
+
async function retainExistingFiles(fs, cwd, paths) {
|
|
914
|
+
if (paths.length === 0) return /* @__PURE__ */ new Set();
|
|
915
|
+
const existing = /* @__PURE__ */ new Set();
|
|
916
|
+
for (const path of paths) {
|
|
917
|
+
if (existing.has(path)) continue;
|
|
918
|
+
try {
|
|
919
|
+
const target = await fs.resolve(path, resolveOptions(cwd, void 0));
|
|
920
|
+
if ((await fs.stat(target))?.type === "file") existing.add(path);
|
|
921
|
+
} catch {}
|
|
922
|
+
}
|
|
923
|
+
return existing;
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* The `list` core: the log fold plus any bash-written files the collector
|
|
927
|
+
* verified, merged so a captured path the fold already knows keeps its
|
|
928
|
+
* log-derived entry, then filtered to paths that still exist on disk. This is
|
|
929
|
+
* the exact body the Remote `list` runs.
|
|
930
|
+
* @param core - the fs face, the path anchor, and the entry cap.
|
|
931
|
+
* @param events - the session's events in ascending seq order.
|
|
932
|
+
* @param captured - the collector's verified bash writes, when capture is on.
|
|
933
|
+
* @returns existing first-seen files capped by `maxFiles`, with the log watermark.
|
|
934
|
+
*/
|
|
935
|
+
async function listFilePreview(core, events, captured) {
|
|
936
|
+
const folded = foldFilePreview(events, core.maxFiles);
|
|
937
|
+
let entries = folded.entries;
|
|
938
|
+
if (captured !== void 0 && captured.size > 0) {
|
|
939
|
+
const seen = new Set(entries.map((entry) => entry.path));
|
|
940
|
+
const extra = [];
|
|
941
|
+
for (const [path, write] of captured) {
|
|
942
|
+
if (seen.has(path) || entries.length + extra.length >= core.maxFiles) continue;
|
|
943
|
+
extra.push({
|
|
944
|
+
path,
|
|
945
|
+
op: "write",
|
|
946
|
+
seq: write.seq,
|
|
947
|
+
turn: write.turn,
|
|
948
|
+
step: write.step,
|
|
949
|
+
diffs: []
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
entries = [...entries, ...extra];
|
|
953
|
+
}
|
|
954
|
+
const existing = await retainExistingFiles(core.fs, core.cwd, entries.map((entry) => entry.path));
|
|
955
|
+
return {
|
|
956
|
+
entries: entries.filter((entry) => existing.has(entry.path)),
|
|
957
|
+
asOfSeq: folded.asOfSeq,
|
|
958
|
+
truncated: folded.truncated || entries.length >= core.maxFiles
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* The `turnFiles` core: merge the per-turn fold with the collector's captured
|
|
963
|
+
* writes by their own turn, then filter every group to paths that still exist
|
|
964
|
+
* on disk (fresh per call — the fold cache is by log watermark, existence
|
|
965
|
+
* moves independently). A path touched in two turns appears in BOTH turn
|
|
966
|
+
* groups. This is the exact body the Remote `turnFiles` runs; the caller owns
|
|
967
|
+
* the by-turn fold (and any cache of it) so the watermark discipline sits
|
|
968
|
+
* next to the caller's own cache.
|
|
969
|
+
* @param core - the fs face and the path anchor (`maxFiles` goes unread here).
|
|
970
|
+
* @param asOfSeq - the watermark of the scanned log ({@link eventsWatermark}).
|
|
971
|
+
* @param byTurn - the per-turn fold over the same events.
|
|
972
|
+
* @param captured - the collector's verified bash writes, when capture is on.
|
|
973
|
+
* @returns existing per-turn file groups plus the scanned watermark.
|
|
974
|
+
*/
|
|
975
|
+
async function turnFilesFilePreview(core, asOfSeq, byTurn, captured) {
|
|
976
|
+
const turns = [];
|
|
977
|
+
const turnNumbers = new Set(byTurn.keys());
|
|
978
|
+
if (captured !== void 0) for (const write of captured.values()) turnNumbers.add(write.turn);
|
|
979
|
+
for (const turn of [...turnNumbers].sort((a, b) => a - b)) {
|
|
980
|
+
const files = [];
|
|
981
|
+
const map = byTurn.get(turn);
|
|
982
|
+
if (map !== void 0) files.push(...map.values());
|
|
983
|
+
if (captured !== void 0) for (const [path, write] of captured) {
|
|
984
|
+
if (write.turn !== turn || files.some((file) => file.path === path)) continue;
|
|
985
|
+
files.push({
|
|
986
|
+
path,
|
|
987
|
+
seq: write.seq,
|
|
988
|
+
step: write.step
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
if (files.length === 0) continue;
|
|
992
|
+
turns.push({
|
|
993
|
+
turn,
|
|
994
|
+
files: files.sort((a, b) => a.seq - b.seq)
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
const existing = await retainExistingFiles(core.fs, core.cwd, turns.flatMap((group) => group.files.map((file) => file.path)));
|
|
998
|
+
return {
|
|
999
|
+
turns: turns.map((group) => ({
|
|
1000
|
+
turn: group.turn,
|
|
1001
|
+
files: group.files.filter((file) => existing.has(file.path))
|
|
1002
|
+
})).filter((group) => group.files.length > 0),
|
|
1003
|
+
asOfSeq
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* The `read` core: resolve the recorded display path against the anchor and
|
|
1008
|
+
* serve current text content, capped per kind. The text read is a bounded
|
|
1009
|
+
* byte window (`readByteRange` of cap + 1 bytes), so an oversized file costs
|
|
1010
|
+
* the cap, never its full size; a known oversized stat still short-circuits
|
|
1011
|
+
* to `too-large` before any bytes move. This is the exact body the Remote
|
|
1012
|
+
* `read` runs.
|
|
1013
|
+
* @param core - the fs face, the path anchor, the caps, and the image issuer.
|
|
1014
|
+
* @param path - the display path recorded by the write/edit tool call.
|
|
1015
|
+
* @param signal - cooperative cancellation from the calling request.
|
|
1016
|
+
* @returns the classified read; `text` carries content, `image` carries the
|
|
1017
|
+
* issued URL, other kinds carry the size or a human-readable reason.
|
|
1018
|
+
*/
|
|
1019
|
+
async function readFilePreview(core, path, signal) {
|
|
1020
|
+
if (typeof path !== "string" || path.length === 0) return {
|
|
1021
|
+
path,
|
|
1022
|
+
kind: "error",
|
|
1023
|
+
message: "file preview requires a non-empty path"
|
|
1024
|
+
};
|
|
1025
|
+
let target;
|
|
1026
|
+
try {
|
|
1027
|
+
target = await core.fs.resolve(path, resolveOptions(core.cwd, signal));
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
return {
|
|
1030
|
+
path,
|
|
1031
|
+
kind: "error",
|
|
1032
|
+
message: readErrorMessage(error)
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
let info;
|
|
1036
|
+
try {
|
|
1037
|
+
info = await core.fs.stat(target, signal);
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
return {
|
|
1040
|
+
path,
|
|
1041
|
+
kind: "error",
|
|
1042
|
+
message: readErrorMessage(error)
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
if (info === void 0 || info.type !== "file") return {
|
|
1046
|
+
path,
|
|
1047
|
+
kind: "missing"
|
|
1048
|
+
};
|
|
1049
|
+
const html = isHtmlPath(path);
|
|
1050
|
+
const cap = html ? core.htmlMaxReadBytes : core.maxReadBytes;
|
|
1051
|
+
if (info.size !== void 0 && info.size > cap) return {
|
|
1052
|
+
path,
|
|
1053
|
+
kind: "too-large",
|
|
1054
|
+
size: info.size
|
|
1055
|
+
};
|
|
1056
|
+
if (isImagePath(path)) {
|
|
1057
|
+
const url = core.imageUrl?.(path);
|
|
1058
|
+
if (url !== void 0) return {
|
|
1059
|
+
path,
|
|
1060
|
+
kind: "image",
|
|
1061
|
+
url,
|
|
1062
|
+
...info.size === void 0 ? {} : { size: info.size }
|
|
1063
|
+
};
|
|
1064
|
+
return {
|
|
1065
|
+
path,
|
|
1066
|
+
kind: "binary",
|
|
1067
|
+
...info.size === void 0 ? {} : { size: info.size }
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
if (isBinaryPath(path)) return {
|
|
1071
|
+
path,
|
|
1072
|
+
kind: "binary",
|
|
1073
|
+
...info.size === void 0 ? {} : { size: info.size }
|
|
1074
|
+
};
|
|
1075
|
+
try {
|
|
1076
|
+
const bytes = await core.fs.readByteRange(target, {
|
|
1077
|
+
offset: 0,
|
|
1078
|
+
length: cap + 1
|
|
1079
|
+
}, signal);
|
|
1080
|
+
const truncated = bytes.length > cap;
|
|
1081
|
+
const content = utf8Decoder.decode(truncated ? bytes.subarray(0, cap) : bytes);
|
|
1082
|
+
if (hasNulByte(content)) return {
|
|
1083
|
+
path,
|
|
1084
|
+
kind: "binary",
|
|
1085
|
+
...info.size === void 0 ? {} : { size: info.size }
|
|
1086
|
+
};
|
|
1087
|
+
return {
|
|
1088
|
+
path,
|
|
1089
|
+
kind: "text",
|
|
1090
|
+
content,
|
|
1091
|
+
truncated,
|
|
1092
|
+
...html && isScriptedHtml(content) ? { htmlScripted: true } : {},
|
|
1093
|
+
...info.size === void 0 ? {} : { size: info.size }
|
|
1094
|
+
};
|
|
1095
|
+
} catch (error) {
|
|
1096
|
+
return {
|
|
1097
|
+
path,
|
|
1098
|
+
kind: "error",
|
|
1099
|
+
message: readErrorMessage(error)
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* The authorized image byte read: the same resolution, anchor, containment,
|
|
1105
|
+
* and caps as {@link readFilePreview}, returning the bytes themselves instead
|
|
1106
|
+
* of an address — the consuming side serves them through its own
|
|
1107
|
+
* authenticated channel. The failure taxonomy mirrors `read` (`missing`,
|
|
1108
|
+
* `too-large`, `error`) plus `not-image` for a path without an image
|
|
1109
|
+
* extension.
|
|
1110
|
+
* @param core - the fs face, the path anchor, and the byte cap.
|
|
1111
|
+
* @param path - the display path recorded by the session.
|
|
1112
|
+
* @param signal - cooperative cancellation from the calling request.
|
|
1113
|
+
* @returns the bytes and MIME type, or the classified failure.
|
|
1114
|
+
*/
|
|
1115
|
+
async function readFilePreviewImageBytes(core, path, signal) {
|
|
1116
|
+
if (typeof path !== "string" || path.length === 0) return {
|
|
1117
|
+
path,
|
|
1118
|
+
ok: false,
|
|
1119
|
+
reason: "error",
|
|
1120
|
+
message: "file preview requires a non-empty path"
|
|
1121
|
+
};
|
|
1122
|
+
if (!isImagePath(path)) return {
|
|
1123
|
+
path,
|
|
1124
|
+
ok: false,
|
|
1125
|
+
reason: "not-image"
|
|
1126
|
+
};
|
|
1127
|
+
let target;
|
|
1128
|
+
try {
|
|
1129
|
+
target = await core.fs.resolve(path, resolveOptions(core.cwd, signal));
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
return {
|
|
1132
|
+
path,
|
|
1133
|
+
ok: false,
|
|
1134
|
+
reason: "error",
|
|
1135
|
+
message: readErrorMessage(error)
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
let info;
|
|
1139
|
+
try {
|
|
1140
|
+
info = await core.fs.stat(target, signal);
|
|
1141
|
+
} catch (error) {
|
|
1142
|
+
return {
|
|
1143
|
+
path,
|
|
1144
|
+
ok: false,
|
|
1145
|
+
reason: "error",
|
|
1146
|
+
message: readErrorMessage(error)
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
if (info === void 0 || info.type !== "file") return {
|
|
1150
|
+
path,
|
|
1151
|
+
ok: false,
|
|
1152
|
+
reason: "missing"
|
|
1153
|
+
};
|
|
1154
|
+
if (info.size !== void 0 && info.size > core.maxReadBytes) return {
|
|
1155
|
+
path,
|
|
1156
|
+
ok: false,
|
|
1157
|
+
reason: "too-large",
|
|
1158
|
+
size: info.size
|
|
1159
|
+
};
|
|
1160
|
+
try {
|
|
1161
|
+
const bytes = await core.fs.readBytes(target, signal, core.maxReadBytes);
|
|
1162
|
+
const dot = path.lastIndexOf(".");
|
|
1163
|
+
return {
|
|
1164
|
+
path,
|
|
1165
|
+
ok: true,
|
|
1166
|
+
mime: IMAGE_MIME[path.slice(dot).toLowerCase()] ?? "application/octet-stream",
|
|
1167
|
+
bytes,
|
|
1168
|
+
...info.size === void 0 ? {} : { size: info.size }
|
|
1169
|
+
};
|
|
1170
|
+
} catch (error) {
|
|
1171
|
+
return {
|
|
1172
|
+
path,
|
|
1173
|
+
ok: false,
|
|
1174
|
+
reason: "error",
|
|
1175
|
+
message: readErrorMessage(error)
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* Build a read-only file-preview surface over an externally-authorized
|
|
1181
|
+
* session source. The surface runs the same core functions the Remote methods
|
|
1182
|
+
* run, so for the same session its answers are byte-identical to the Remote
|
|
1183
|
+
* face's — the bash-write merge included, rebuilt from the same events
|
|
1184
|
+
* through the same collector pipeline. The factory itself holds no state: the
|
|
1185
|
+
* caches live in the returned instance and die with {@link FilePreviewReadSurface.dispose}.
|
|
1186
|
+
* @param source - the caller-authorized events, anchor, fs face, and issuer.
|
|
1187
|
+
* @returns the read surface.
|
|
1188
|
+
*/
|
|
1189
|
+
function createFilePreviewReadSurface(source) {
|
|
1190
|
+
const resolved = {
|
|
1191
|
+
maxReadBytes: source.maxReadBytes ?? 524288,
|
|
1192
|
+
htmlMaxReadBytes: source.htmlMaxReadBytes ?? 4194304,
|
|
1193
|
+
maxFiles: source.maxFiles ?? 500
|
|
1194
|
+
};
|
|
1195
|
+
const cwd = source.cwd;
|
|
1196
|
+
const collector = source.captureBashWrites === false ? void 0 : new BashWriteCollector({
|
|
1197
|
+
fs: source.fs,
|
|
1198
|
+
maxFiles: resolved.maxFiles
|
|
1199
|
+
});
|
|
1200
|
+
/** Derived caches keyed by the log watermark; `dispose` drops both. */
|
|
1201
|
+
let turnCache;
|
|
1202
|
+
let captureCache;
|
|
1203
|
+
/** The verified bash-write captures for the current watermark (replayed on change). */
|
|
1204
|
+
const capturedAt = async (events, asOfSeq) => {
|
|
1205
|
+
if (collector === void 0) return void 0;
|
|
1206
|
+
if (captureCache !== void 0 && captureCache.asOfSeq === asOfSeq) return captureCache.captured;
|
|
1207
|
+
const captured = await collector.replaySnapshot(cwd, events);
|
|
1208
|
+
captureCache = {
|
|
1209
|
+
asOfSeq,
|
|
1210
|
+
captured
|
|
1211
|
+
};
|
|
1212
|
+
return captured;
|
|
1213
|
+
};
|
|
1214
|
+
return {
|
|
1215
|
+
list: async () => {
|
|
1216
|
+
const events = source.events();
|
|
1217
|
+
const captured = await capturedAt(events, eventsWatermark(events));
|
|
1218
|
+
return listFilePreview({
|
|
1219
|
+
fs: source.fs,
|
|
1220
|
+
cwd,
|
|
1221
|
+
maxFiles: resolved.maxFiles
|
|
1222
|
+
}, events, captured);
|
|
1223
|
+
},
|
|
1224
|
+
turnFiles: async () => {
|
|
1225
|
+
const events = source.events();
|
|
1226
|
+
const asOfSeq = eventsWatermark(events);
|
|
1227
|
+
let byTurn;
|
|
1228
|
+
if (turnCache !== void 0 && turnCache.asOfSeq === asOfSeq) byTurn = turnCache.byTurn;
|
|
1229
|
+
else {
|
|
1230
|
+
byTurn = foldFilePreviewByTurn(events);
|
|
1231
|
+
turnCache = {
|
|
1232
|
+
asOfSeq,
|
|
1233
|
+
byTurn
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
const captured = await capturedAt(events, asOfSeq);
|
|
1237
|
+
return turnFilesFilePreview({
|
|
1238
|
+
fs: source.fs,
|
|
1239
|
+
cwd,
|
|
1240
|
+
maxFiles: resolved.maxFiles
|
|
1241
|
+
}, asOfSeq, byTurn, captured);
|
|
1242
|
+
},
|
|
1243
|
+
read: (path, signal) => readFilePreview({
|
|
1244
|
+
fs: source.fs,
|
|
1245
|
+
cwd,
|
|
1246
|
+
maxReadBytes: resolved.maxReadBytes,
|
|
1247
|
+
htmlMaxReadBytes: resolved.htmlMaxReadBytes,
|
|
1248
|
+
imageUrl: source.imageUrl
|
|
1249
|
+
}, path, signal),
|
|
1250
|
+
readImageBytes: (path, signal) => readFilePreviewImageBytes({
|
|
1251
|
+
fs: source.fs,
|
|
1252
|
+
cwd,
|
|
1253
|
+
maxReadBytes: resolved.maxReadBytes
|
|
1254
|
+
}, path, signal),
|
|
1255
|
+
dispose: () => {
|
|
1256
|
+
turnCache = void 0;
|
|
1257
|
+
captureCache = void 0;
|
|
1258
|
+
}
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
//#endregion
|
|
728
1262
|
//#region lib/types/reveal.js
|
|
729
1263
|
/**
|
|
730
1264
|
* Cross-platform native "show in folder, selecting the file" dispatch used by
|
|
@@ -842,98 +1376,6 @@ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializ
|
|
|
842
1376
|
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
843
1377
|
done = true;
|
|
844
1378
|
};
|
|
845
|
-
/** Extensions treated as binary without reading (their text decode is meaningless). */
|
|
846
|
-
const BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
847
|
-
".pdf",
|
|
848
|
-
".zip",
|
|
849
|
-
".gz",
|
|
850
|
-
".tar",
|
|
851
|
-
".7z",
|
|
852
|
-
".zstd",
|
|
853
|
-
".br",
|
|
854
|
-
".woff",
|
|
855
|
-
".woff2",
|
|
856
|
-
".ttf",
|
|
857
|
-
".otf",
|
|
858
|
-
".eot",
|
|
859
|
-
".wasm",
|
|
860
|
-
".node",
|
|
861
|
-
".dylib",
|
|
862
|
-
".so",
|
|
863
|
-
".dll",
|
|
864
|
-
".exe",
|
|
865
|
-
".kext",
|
|
866
|
-
".class",
|
|
867
|
-
".jar",
|
|
868
|
-
".pyc",
|
|
869
|
-
".sqlite",
|
|
870
|
-
".db",
|
|
871
|
-
".DS_Store",
|
|
872
|
-
".a",
|
|
873
|
-
".o"
|
|
874
|
-
]);
|
|
875
|
-
/** Image extensions served to the browser through the image route. */
|
|
876
|
-
const IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
877
|
-
".png",
|
|
878
|
-
".jpg",
|
|
879
|
-
".jpeg",
|
|
880
|
-
".gif",
|
|
881
|
-
".webp",
|
|
882
|
-
".avif",
|
|
883
|
-
".bmp",
|
|
884
|
-
".ico",
|
|
885
|
-
".svg"
|
|
886
|
-
]);
|
|
887
|
-
/** MIME type per image extension for the image route's Content-Type header. */
|
|
888
|
-
const IMAGE_MIME = {
|
|
889
|
-
".png": "image/png",
|
|
890
|
-
".jpg": "image/jpeg",
|
|
891
|
-
".jpeg": "image/jpeg",
|
|
892
|
-
".gif": "image/gif",
|
|
893
|
-
".webp": "image/webp",
|
|
894
|
-
".avif": "image/avif",
|
|
895
|
-
".bmp": "image/bmp",
|
|
896
|
-
".ico": "image/x-icon",
|
|
897
|
-
".svg": "image/svg+xml"
|
|
898
|
-
};
|
|
899
|
-
/** NUL bytes in decoded text mark a file as binary despite a successful decode. */
|
|
900
|
-
function hasNulByte(content) {
|
|
901
|
-
return content.slice(0, 4096).includes("\0");
|
|
902
|
-
}
|
|
903
|
-
/** Shared non-fatal UTF-8 decoder for the bounded byte-window read: invalid
|
|
904
|
-
* sequences become U+FFFD (a cut multi-byte character at the window edge
|
|
905
|
-
* included) instead of failing the read. */
|
|
906
|
-
const utf8Decoder = new TextDecoder();
|
|
907
|
-
/** Detect a binary file from its display path extension. */
|
|
908
|
-
function isBinaryPath(path) {
|
|
909
|
-
const dot = path.lastIndexOf(".");
|
|
910
|
-
return dot >= 0 && BINARY_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
911
|
-
}
|
|
912
|
-
/** Detect an image from its display path extension. */
|
|
913
|
-
function isImagePath(path) {
|
|
914
|
-
const dot = path.lastIndexOf(".");
|
|
915
|
-
return dot >= 0 && IMAGE_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
916
|
-
}
|
|
917
|
-
/** HTML extensions served through the sandboxed render channel. */
|
|
918
|
-
const HTML_EXTENSIONS = /* @__PURE__ */ new Set([".html", ".htm"]);
|
|
919
|
-
/** Detect an HTML document from its display path extension. */
|
|
920
|
-
function isHtmlPath(path) {
|
|
921
|
-
const dot = path.lastIndexOf(".");
|
|
922
|
-
return dot >= 0 && HTML_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
923
|
-
}
|
|
924
|
-
/** Best-effort scripted-HTML detection: any `<script>` tag, inline event
|
|
925
|
-
* handler (`on*="…"`), or `javascript:` URL marks the document as scripted.
|
|
926
|
-
* Only a hint for default-mode selection and warning — never a trust
|
|
927
|
-
* decision; the sandbox and the Tier1 CSP are the real boundary. The scan
|
|
928
|
-
* runs on the read content as delivered (a truncated read may miss markers
|
|
929
|
-
* in the tail; acceptable for a hint). */
|
|
930
|
-
function isScriptedHtml(content) {
|
|
931
|
-
return /<\s*script[\s>]/i.test(content) || /\son[a-z]+\s*=/i.test(content) || /javascript:/i.test(content);
|
|
932
|
-
}
|
|
933
|
-
/** Stable error text for a failed read, without leaking backend internals. */
|
|
934
|
-
function readErrorMessage(error) {
|
|
935
|
-
return error instanceof Error ? error.message : String(error);
|
|
936
|
-
}
|
|
937
1379
|
/**
|
|
938
1380
|
* The `filePreview` Remote service: session-scoped read-only file inspection
|
|
939
1381
|
* for web surfaces. `list` is a fold over the session log, then filtered to the
|
|
@@ -1044,10 +1486,9 @@ let FilePreviewService = (() => {
|
|
|
1044
1486
|
/** URL prefix the image route answers under. */
|
|
1045
1487
|
static IMAGE_ROUTE = "/file-preview-image";
|
|
1046
1488
|
resolved = __runInitializers(this, _instanceExtraInitializers);
|
|
1047
|
-
/**
|
|
1048
|
-
*
|
|
1049
|
-
|
|
1050
|
-
/** The agents service when present (image route only; null headless). */
|
|
1489
|
+
/** The agents service while the image route is registered; undefined
|
|
1490
|
+
* otherwise (headless faces, or before `webServer`/`agents` arrive), and
|
|
1491
|
+
* image reads then answer `binary` (no browser to serve). */
|
|
1051
1492
|
agents;
|
|
1052
1493
|
/** Native reveal runner; a constructor seam for deterministic tests. */
|
|
1053
1494
|
revealNative;
|
|
@@ -1059,7 +1500,8 @@ let FilePreviewService = (() => {
|
|
|
1059
1500
|
turnCache = /* @__PURE__ */ new Map();
|
|
1060
1501
|
/**
|
|
1061
1502
|
* @param ctx - owning Cordis Context carrying `fs`; `webServer` and `agents`
|
|
1062
|
-
* are optional (the image route
|
|
1503
|
+
* are optional (the image route registers whenever both are up, headless
|
|
1504
|
+
* faces lack both).
|
|
1063
1505
|
* @param config - optional deployment tuning; defaults cap reads and lists,
|
|
1064
1506
|
* and enable the bash-write collector.
|
|
1065
1507
|
* @param deps - test seam for the native reveal dispatch.
|
|
@@ -1081,19 +1523,27 @@ let FilePreviewService = (() => {
|
|
|
1081
1523
|
});
|
|
1082
1524
|
ctx.effect(() => this.collector.attach(ctx), "file-preview: bash-write capture");
|
|
1083
1525
|
} else this.collector = void 0;
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1526
|
+
ctx.inject(["webServer", "agents"], (routeCtx) => {
|
|
1527
|
+
const webServer = routeCtx.get("webServer");
|
|
1528
|
+
const agents = routeCtx.get("agents");
|
|
1529
|
+
routeCtx.effect(() => {
|
|
1530
|
+
const unregister = webServer.register({
|
|
1531
|
+
kind: "prefix",
|
|
1532
|
+
path: FilePreviewService.IMAGE_ROUTE,
|
|
1533
|
+
/* v8 ignore next 2 -- the handler body is the request path; the
|
|
1534
|
+
unit tests drive serveImage directly, the real server reaches it
|
|
1535
|
+
through this registration */
|
|
1536
|
+
handler: (req, res) => {
|
|
1537
|
+
this.serveImage(req, res);
|
|
1538
|
+
}
|
|
1539
|
+
});
|
|
1540
|
+
this.agents = agents;
|
|
1541
|
+
return () => {
|
|
1542
|
+
this.agents = void 0;
|
|
1543
|
+
unregister();
|
|
1544
|
+
};
|
|
1545
|
+
}, "file-preview: image route");
|
|
1546
|
+
});
|
|
1097
1547
|
}
|
|
1098
1548
|
/** Zero-session availability handshake for browser companions. */
|
|
1099
1549
|
capabilities() {
|
|
@@ -1173,30 +1623,6 @@ let FilePreviewService = (() => {
|
|
|
1173
1623
|
});
|
|
1174
1624
|
res.end(Buffer.from(bytes));
|
|
1175
1625
|
}
|
|
1176
|
-
/** Resolve the session's recorded display paths against its cwd and keep only
|
|
1177
|
-
* those that currently exist as a regular file. The products surfaces fold
|
|
1178
|
-
* the session log (which is history, not disk state): a path the log wrote
|
|
1179
|
-
* may have been cleaned up since (a temp script the turn deleted), and such
|
|
1180
|
-
* a path is not a product to list. Existence is checked fresh on every call
|
|
1181
|
-
* (never cached), so a deletion that lands after a fold is visible on the
|
|
1182
|
-
* next fetch. Unresolvable/unstat-able paths are treated as absent.
|
|
1183
|
-
* @param agent - owning live agent; its session cwd anchors relative paths.
|
|
1184
|
-
* @param paths - the display paths to probe.
|
|
1185
|
-
* @returns the subset that resolves to an existing regular file.
|
|
1186
|
-
*/
|
|
1187
|
-
async retainExisting(agent, paths) {
|
|
1188
|
-
if (paths.length === 0) return /* @__PURE__ */ new Set();
|
|
1189
|
-
const cwd = agent.session.header.cwd;
|
|
1190
|
-
const existing = /* @__PURE__ */ new Set();
|
|
1191
|
-
for (const path of paths) {
|
|
1192
|
-
if (existing.has(path)) continue;
|
|
1193
|
-
try {
|
|
1194
|
-
const target = await this.fs.resolve(path, cwd === void 0 ? {} : { cwd });
|
|
1195
|
-
if ((await this.fs.stat(target))?.type === "file") existing.add(path);
|
|
1196
|
-
} catch {}
|
|
1197
|
-
}
|
|
1198
|
-
return existing;
|
|
1199
|
-
}
|
|
1200
1626
|
/**
|
|
1201
1627
|
* List the files one session wrote or edited: the log fold plus any
|
|
1202
1628
|
* bash-written files the collector verified (the S2 seam), merged so a
|
|
@@ -1207,31 +1633,11 @@ let FilePreviewService = (() => {
|
|
|
1207
1633
|
* @returns existing first-seen files capped by `maxFiles`, with the log watermark.
|
|
1208
1634
|
*/
|
|
1209
1635
|
async list(agent) {
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
const extra = [];
|
|
1216
|
-
for (const [path, write] of captured) {
|
|
1217
|
-
if (seen.has(path) || entries.length + extra.length >= this.resolved.maxFiles) continue;
|
|
1218
|
-
extra.push({
|
|
1219
|
-
path,
|
|
1220
|
-
op: "write",
|
|
1221
|
-
seq: write.seq,
|
|
1222
|
-
turn: write.turn,
|
|
1223
|
-
step: write.step,
|
|
1224
|
-
diffs: []
|
|
1225
|
-
});
|
|
1226
|
-
}
|
|
1227
|
-
entries = [...entries, ...extra];
|
|
1228
|
-
}
|
|
1229
|
-
const existing = await this.retainExisting(agent, entries.map((entry) => entry.path));
|
|
1230
|
-
return {
|
|
1231
|
-
entries: entries.filter((entry) => existing.has(entry.path)),
|
|
1232
|
-
asOfSeq: folded.asOfSeq,
|
|
1233
|
-
truncated: folded.truncated || entries.length >= this.resolved.maxFiles
|
|
1234
|
-
};
|
|
1636
|
+
return listFilePreview({
|
|
1637
|
+
fs: this.fs,
|
|
1638
|
+
cwd: agent.session.header?.cwd,
|
|
1639
|
+
maxFiles: this.resolved.maxFiles
|
|
1640
|
+
}, agent.session.snapshotEvents(), this.collector?.captured(agent.session.id));
|
|
1235
1641
|
}
|
|
1236
1642
|
/**
|
|
1237
1643
|
* Every turn's file mutations for the turn-tail card — the single source of
|
|
@@ -1251,7 +1657,7 @@ let FilePreviewService = (() => {
|
|
|
1251
1657
|
async turnFiles(agent) {
|
|
1252
1658
|
const session = agent.session;
|
|
1253
1659
|
const events = session.snapshotEvents();
|
|
1254
|
-
const asOfSeq = events
|
|
1660
|
+
const asOfSeq = eventsWatermark(events);
|
|
1255
1661
|
const cached = this.turnCache.get(session.id);
|
|
1256
1662
|
let byTurn;
|
|
1257
1663
|
if (cached === void 0 || cached.asOfSeq !== asOfSeq) {
|
|
@@ -1261,36 +1667,11 @@ let FilePreviewService = (() => {
|
|
|
1261
1667
|
byTurn
|
|
1262
1668
|
});
|
|
1263
1669
|
} else byTurn = cached.byTurn;
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
const files = [];
|
|
1270
|
-
const map = byTurn.get(turn);
|
|
1271
|
-
if (map !== void 0) files.push(...map.values());
|
|
1272
|
-
if (captured !== void 0) for (const [path, write] of captured) {
|
|
1273
|
-
if (write.turn !== turn || files.some((file) => file.path === path)) continue;
|
|
1274
|
-
files.push({
|
|
1275
|
-
path,
|
|
1276
|
-
seq: write.seq,
|
|
1277
|
-
step: write.step
|
|
1278
|
-
});
|
|
1279
|
-
}
|
|
1280
|
-
if (files.length === 0) continue;
|
|
1281
|
-
turns.push({
|
|
1282
|
-
turn,
|
|
1283
|
-
files: files.sort((a, b) => a.seq - b.seq)
|
|
1284
|
-
});
|
|
1285
|
-
}
|
|
1286
|
-
const existing = await this.retainExisting(agent, turns.flatMap((group) => group.files.map((file) => file.path)));
|
|
1287
|
-
return {
|
|
1288
|
-
turns: turns.map((group) => ({
|
|
1289
|
-
turn: group.turn,
|
|
1290
|
-
files: group.files.filter((file) => existing.has(file.path))
|
|
1291
|
-
})).filter((group) => group.files.length > 0),
|
|
1292
|
-
asOfSeq
|
|
1293
|
-
};
|
|
1670
|
+
return turnFilesFilePreview({
|
|
1671
|
+
fs: this.fs,
|
|
1672
|
+
cwd: session.header?.cwd,
|
|
1673
|
+
maxFiles: this.resolved.maxFiles
|
|
1674
|
+
}, asOfSeq, byTurn, this.collector?.captured(session.id));
|
|
1294
1675
|
}
|
|
1295
1676
|
/**
|
|
1296
1677
|
* Read the current content of one recorded file, resolved against the
|
|
@@ -1305,94 +1686,14 @@ let FilePreviewService = (() => {
|
|
|
1305
1686
|
* browser URL, other kinds carry the size or a human-readable reason.
|
|
1306
1687
|
*/
|
|
1307
1688
|
async read(agent, path, signal) {
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
target = await this.fs.resolve(path, cwd === void 0 ? { signal } : {
|
|
1317
|
-
cwd,
|
|
1318
|
-
signal
|
|
1319
|
-
});
|
|
1320
|
-
} catch (error) {
|
|
1321
|
-
return {
|
|
1322
|
-
path,
|
|
1323
|
-
kind: "error",
|
|
1324
|
-
message: readErrorMessage(error)
|
|
1325
|
-
};
|
|
1326
|
-
}
|
|
1327
|
-
let info;
|
|
1328
|
-
try {
|
|
1329
|
-
info = await this.fs.stat(target, signal);
|
|
1330
|
-
} catch (error) {
|
|
1331
|
-
return {
|
|
1332
|
-
path,
|
|
1333
|
-
kind: "error",
|
|
1334
|
-
message: readErrorMessage(error)
|
|
1335
|
-
};
|
|
1336
|
-
}
|
|
1337
|
-
if (info === void 0 || info.type !== "file") return {
|
|
1338
|
-
path,
|
|
1339
|
-
kind: "missing"
|
|
1340
|
-
};
|
|
1341
|
-
const html = isHtmlPath(path);
|
|
1342
|
-
const cap = html ? this.resolved.htmlMaxReadBytes : this.resolved.maxReadBytes;
|
|
1343
|
-
if (info.size !== void 0 && info.size > cap) return {
|
|
1344
|
-
path,
|
|
1345
|
-
kind: "too-large",
|
|
1346
|
-
size: info.size
|
|
1347
|
-
};
|
|
1348
|
-
if (isImagePath(path)) {
|
|
1349
|
-
if (this.imageRoute) {
|
|
1350
|
-
const encoded = encodeURIComponent(path);
|
|
1351
|
-
return {
|
|
1352
|
-
path,
|
|
1353
|
-
kind: "image",
|
|
1354
|
-
url: `${FilePreviewService.IMAGE_ROUTE}/${agent.session.id}/${encoded}`,
|
|
1355
|
-
...info.size === void 0 ? {} : { size: info.size }
|
|
1356
|
-
};
|
|
1357
|
-
}
|
|
1358
|
-
return {
|
|
1359
|
-
path,
|
|
1360
|
-
kind: "binary",
|
|
1361
|
-
...info.size === void 0 ? {} : { size: info.size }
|
|
1362
|
-
};
|
|
1363
|
-
}
|
|
1364
|
-
if (isBinaryPath(path)) return {
|
|
1365
|
-
path,
|
|
1366
|
-
kind: "binary",
|
|
1367
|
-
...info.size === void 0 ? {} : { size: info.size }
|
|
1368
|
-
};
|
|
1369
|
-
try {
|
|
1370
|
-
const bytes = await this.fs.readByteRange(target, {
|
|
1371
|
-
offset: 0,
|
|
1372
|
-
length: cap + 1
|
|
1373
|
-
}, signal);
|
|
1374
|
-
const truncated = bytes.length > cap;
|
|
1375
|
-
const content = utf8Decoder.decode(truncated ? bytes.subarray(0, cap) : bytes);
|
|
1376
|
-
if (hasNulByte(content)) return {
|
|
1377
|
-
path,
|
|
1378
|
-
kind: "binary",
|
|
1379
|
-
...info.size === void 0 ? {} : { size: info.size }
|
|
1380
|
-
};
|
|
1381
|
-
return {
|
|
1382
|
-
path,
|
|
1383
|
-
kind: "text",
|
|
1384
|
-
content,
|
|
1385
|
-
truncated,
|
|
1386
|
-
...html && isScriptedHtml(content) ? { htmlScripted: true } : {},
|
|
1387
|
-
...info.size === void 0 ? {} : { size: info.size }
|
|
1388
|
-
};
|
|
1389
|
-
} catch (error) {
|
|
1390
|
-
return {
|
|
1391
|
-
path,
|
|
1392
|
-
kind: "error",
|
|
1393
|
-
message: readErrorMessage(error)
|
|
1394
|
-
};
|
|
1395
|
-
}
|
|
1689
|
+
const session = agent.session;
|
|
1690
|
+
return readFilePreview({
|
|
1691
|
+
fs: this.fs,
|
|
1692
|
+
cwd: session.header.cwd,
|
|
1693
|
+
maxReadBytes: this.resolved.maxReadBytes,
|
|
1694
|
+
htmlMaxReadBytes: this.resolved.htmlMaxReadBytes,
|
|
1695
|
+
imageUrl: this.agents !== void 0 ? (display) => `${FilePreviewService.IMAGE_ROUTE}/${session.id}/${encodeURIComponent(display)}` : void 0
|
|
1696
|
+
}, path, signal);
|
|
1396
1697
|
}
|
|
1397
1698
|
/**
|
|
1398
1699
|
* Reveal one recorded file in the host file manager — open its folder and
|
|
@@ -1505,4 +1806,4 @@ let FilePreviewService = (() => {
|
|
|
1505
1806
|
};
|
|
1506
1807
|
})();
|
|
1507
1808
|
//#endregion
|
|
1508
|
-
export { FilePreviewService, FilePreviewService as default };
|
|
1809
|
+
export { FilePreviewService, FilePreviewService as default, createFilePreviewReadSurface };
|