@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/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
- /** Whether a web server is present to host the image route; image reads
1048
- * answer `binary` without it (the headless face has no browser to serve). */
1049
- imageRoute;
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 needs them, headless faces lack both).
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
- const webServer = ctx.get("webServer");
1085
- this.agents = ctx.get("agents");
1086
- this.imageRoute = webServer !== void 0 && this.agents !== void 0;
1087
- if (this.imageRoute && webServer !== void 0) ctx.effect(() => webServer.register({
1088
- kind: "prefix",
1089
- path: FilePreviewService.IMAGE_ROUTE,
1090
- /* v8 ignore next 2 -- the handler body is the request path; the
1091
- unit tests drive serveImage directly, the real server reaches it
1092
- through this registration */
1093
- handler: (req, res) => {
1094
- this.serveImage(req, res);
1095
- }
1096
- }), "file-preview: image route");
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
- const folded = foldFilePreview(agent.session.snapshotEvents(), this.resolved.maxFiles);
1211
- const captured = this.collector?.captured(agent.session.id);
1212
- let entries = folded.entries;
1213
- if (captured !== void 0 && captured.size > 0) {
1214
- const seen = new Set(entries.map((entry) => entry.path));
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.length === 0 ? -1 : events[events.length - 1].seq;
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
- const captured = this.collector?.captured(session.id);
1265
- const turns = [];
1266
- const turnNumbers = new Set(byTurn.keys());
1267
- if (captured !== void 0) for (const write of captured.values()) turnNumbers.add(write.turn);
1268
- for (const turn of [...turnNumbers].sort((a, b) => a - b)) {
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
- if (typeof path !== "string" || path.length === 0) return {
1309
- path,
1310
- kind: "error",
1311
- message: "file preview requires a non-empty path"
1312
- };
1313
- const cwd = agent.session.header.cwd;
1314
- let target;
1315
- try {
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 };