@khorsheed/dsh-file-preview 0.1.0

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.
@@ -0,0 +1,517 @@
1
+ /** Read-only file-preview Remote service over live sessions and the filesystem.
2
+ * @module @khorsheed/dsh-file-preview
3
+ */
4
+ var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
5
+ var useValue = arguments.length > 2;
6
+ for (var i = 0; i < initializers.length; i++) {
7
+ value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
8
+ }
9
+ return useValue ? value : void 0;
10
+ };
11
+ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
12
+ function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
13
+ var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
14
+ var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
15
+ var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
16
+ var _, done = false;
17
+ for (var i = decorators.length - 1; i >= 0; i--) {
18
+ var context = {};
19
+ for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
20
+ for (var p in contextIn.access) context.access[p] = contextIn.access[p];
21
+ context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
22
+ var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
23
+ if (kind === "accessor") {
24
+ if (result === void 0) continue;
25
+ if (result === null || typeof result !== "object") throw new TypeError("Object expected");
26
+ if (_ = accept(result.get)) descriptor.get = _;
27
+ if (_ = accept(result.set)) descriptor.set = _;
28
+ if (_ = accept(result.init)) initializers.unshift(_);
29
+ }
30
+ else if (_ = accept(result)) {
31
+ if (kind === "field") initializers.unshift(_);
32
+ else descriptor[key] = _;
33
+ }
34
+ }
35
+ if (target) Object.defineProperty(target, contextIn.name, descriptor);
36
+ done = true;
37
+ };
38
+ import { readFileSync } from 'node:fs';
39
+ import { dirname, join } from 'node:path';
40
+ import { fileURLToPath } from 'node:url';
41
+ import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
42
+ import z from '@deepseek-ai/schemastery';
43
+ import { BashWriteCollector } from './bash-writes.js';
44
+ import { foldFilePreview, foldFilePreviewByTurn } from './fold.js';
45
+ import { revealNativePath } from './reveal.js';
46
+ /**
47
+ * Register the 3d-artifact skill — the generation-side contract for
48
+ * sandbox-runnable interactive 3D / digital-twin single-file HTML (self-
49
+ * contained, zero runtime network, GLB-inline zero-fetch models). Pull-based
50
+ * discovery: an agent whose task involves generating such an HTML page finds
51
+ * the contract through the skill catalog — no per-session push notice.
52
+ * Optional: compositions without the skill capability skip the registration.
53
+ * A missing/malformed shipped SKILL.md degrades to a warning — a discovery
54
+ * aid must never take a boot down; the pack-smoke test owns the file's
55
+ * presence in the tarball.
56
+ * @param ctx - plugin context.
57
+ */
58
+ function registerArtifactSkill(ctx) {
59
+ const skills = ctx.get('skills');
60
+ if (skills === undefined) {
61
+ // Not a crash: file preview degrades without the skill (and a minimal
62
+ // composition may legitimately lack the capability). The line exists so a
63
+ // host-API migration that drops or renames the skills service shows up in
64
+ // boot logs instead of failing silently — same diagnostic as ankh-guard's
65
+ // restart-skill registration; grep "skill .* not registered".
66
+ ctx.logger.warn('file-preview: skills capability absent — the 3d-artifact skill is not registered');
67
+ return;
68
+ }
69
+ try {
70
+ const skillFile = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills', '3d-artifact', 'SKILL.md');
71
+ const raw = readFileSync(skillFile, 'utf8');
72
+ const match = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(raw);
73
+ const name = /^name: (.+)$/m.exec(match?.[1] ?? '')?.[1]?.trim();
74
+ const description = /^description: (.+)$/m.exec(match?.[1] ?? '')?.[1]?.trim();
75
+ const content = match?.[2];
76
+ if (match === null || name === undefined || description === undefined || content === undefined) {
77
+ ctx.logger.warn('file-preview: shipped SKILL.md is malformed — the 3d-artifact skill is not registered');
78
+ return;
79
+ }
80
+ ctx.effect(() => skills.register({ name, description, content }));
81
+ }
82
+ catch (error) {
83
+ ctx.logger.warn(`file-preview: shipped SKILL.md unreadable (${String(error)}) — the 3d-artifact skill is not registered`);
84
+ }
85
+ }
86
+ /** Extensions treated as binary without reading (their text decode is meaningless). */
87
+ const BINARY_EXTENSIONS = new Set([
88
+ '.pdf', '.zip', '.gz', '.tar', '.7z', '.zstd', '.br',
89
+ '.woff', '.woff2', '.ttf', '.otf', '.eot',
90
+ '.wasm', '.node', '.dylib', '.so', '.dll', '.exe', '.kext',
91
+ '.class', '.jar', '.pyc', '.sqlite', '.db',
92
+ '.DS_Store', '.a', '.o',
93
+ ]);
94
+ /** Image extensions served to the browser through the image route. */
95
+ const IMAGE_EXTENSIONS = new Set([
96
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.bmp', '.ico', '.svg',
97
+ ]);
98
+ /** MIME type per image extension for the image route's Content-Type header. */
99
+ const IMAGE_MIME = {
100
+ '.png': 'image/png',
101
+ '.jpg': 'image/jpeg',
102
+ '.jpeg': 'image/jpeg',
103
+ '.gif': 'image/gif',
104
+ '.webp': 'image/webp',
105
+ '.avif': 'image/avif',
106
+ '.bmp': 'image/bmp',
107
+ '.ico': 'image/x-icon',
108
+ '.svg': 'image/svg+xml',
109
+ };
110
+ /** NUL bytes in decoded text mark a file as binary despite a successful decode. */
111
+ function hasNulByte(content) {
112
+ return content.slice(0, 4096).includes('\u0000');
113
+ }
114
+ /** Detect a binary file from its display path extension. */
115
+ function isBinaryPath(path) {
116
+ const dot = path.lastIndexOf('.');
117
+ return dot >= 0 && BINARY_EXTENSIONS.has(path.slice(dot).toLowerCase());
118
+ }
119
+ /** Detect an image from its display path extension. */
120
+ function isImagePath(path) {
121
+ const dot = path.lastIndexOf('.');
122
+ return dot >= 0 && IMAGE_EXTENSIONS.has(path.slice(dot).toLowerCase());
123
+ }
124
+ /** HTML extensions served through the sandboxed render channel. */
125
+ const HTML_EXTENSIONS = new Set(['.html', '.htm']);
126
+ /** Detect an HTML document from its display path extension. */
127
+ function isHtmlPath(path) {
128
+ const dot = path.lastIndexOf('.');
129
+ return dot >= 0 && HTML_EXTENSIONS.has(path.slice(dot).toLowerCase());
130
+ }
131
+ /** Best-effort scripted-HTML detection: any `<script>` tag, inline event
132
+ * handler (`on*="…"`), or `javascript:` URL marks the document as scripted.
133
+ * Only a hint for default-mode selection and warning — never a trust
134
+ * decision; the sandbox and the Tier1 CSP are the real boundary. The scan
135
+ * runs on the read content as delivered (a truncated read may miss markers
136
+ * in the tail; acceptable for a hint). */
137
+ function isScriptedHtml(content) {
138
+ return /<\s*script[\s>]/i.test(content)
139
+ || /\son[a-z]+\s*=/i.test(content)
140
+ || /javascript:/i.test(content);
141
+ }
142
+ /** Stable error text for a failed read, without leaking backend internals. */
143
+ function readErrorMessage(error) {
144
+ return error instanceof Error ? error.message : String(error);
145
+ }
146
+ /**
147
+ * The `filePreview` Remote service: session-scoped read-only file inspection
148
+ * for web surfaces. `list` is a pure fold over the session log (no filesystem
149
+ * access); `read` resolves the path against the session cwd and serves current
150
+ * text content through `ctx.fs` (or a browser URL for images), capped by
151
+ * config. Image bytes ride a dedicated host route so the browser loads them
152
+ * natively without bloating the RPC channel.
153
+ */
154
+ let FilePreviewService = (() => {
155
+ let _classSuper = TypertRemoteService;
156
+ let _instanceExtraInitializers = [];
157
+ let _list_decorators;
158
+ let _turnFiles_decorators;
159
+ let _read_decorators;
160
+ let _reveal_decorators;
161
+ return class FilePreviewService extends _classSuper {
162
+ static {
163
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
164
+ _list_decorators = [Remote('list')];
165
+ _turnFiles_decorators = [Remote('turnFiles')];
166
+ _read_decorators = [Remote('read')];
167
+ _reveal_decorators = [Remote('reveal')];
168
+ __esDecorate(this, null, _list_decorators, { kind: "method", name: "list", static: false, private: false, access: { has: obj => "list" in obj, get: obj => obj.list }, metadata: _metadata }, null, _instanceExtraInitializers);
169
+ __esDecorate(this, null, _turnFiles_decorators, { kind: "method", name: "turnFiles", static: false, private: false, access: { has: obj => "turnFiles" in obj, get: obj => obj.turnFiles }, metadata: _metadata }, null, _instanceExtraInitializers);
170
+ __esDecorate(this, null, _read_decorators, { kind: "method", name: "read", static: false, private: false, access: { has: obj => "read" in obj, get: obj => obj.read }, metadata: _metadata }, null, _instanceExtraInitializers);
171
+ __esDecorate(this, null, _reveal_decorators, { kind: "method", name: "reveal", static: false, private: false, access: { has: obj => "reveal" in obj, get: obj => obj.reveal }, metadata: _metadata }, null, _instanceExtraInitializers);
172
+ if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
173
+ }
174
+ static inject = ['fs'];
175
+ static Config = z.object({
176
+ maxReadBytes: z.natural().min(1).default(512 * 1024),
177
+ htmlMaxReadBytes: z.natural().min(1).default(4 * 1024 * 1024),
178
+ maxFiles: z.natural().min(1).default(500),
179
+ captureBashWrites: z.boolean().default(true),
180
+ });
181
+ /** URL prefix the image route answers under. */
182
+ static IMAGE_ROUTE = '/file-preview-image';
183
+ resolved = __runInitializers(this, _instanceExtraInitializers);
184
+ /** Whether a web server is present to host the image route; image reads
185
+ * answer `binary` without it (the headless face has no browser to serve). */
186
+ imageRoute;
187
+ /** The agents service when present (image route only; null headless). */
188
+ agents;
189
+ /** Native reveal runner; a constructor seam for deterministic tests. */
190
+ revealNative;
191
+ /** Bash-write collector (S2 seam), or undefined when disabled in config. */
192
+ collector;
193
+ /** Per-session by-turn fold cache, invalidated by the log watermark. */
194
+ turnCache = new Map();
195
+ /**
196
+ * @param ctx - owning Cordis Context carrying `fs`; `webServer` and `agents`
197
+ * are optional (the image route needs them, headless faces lack both).
198
+ * @param config - optional deployment tuning; defaults cap reads and lists,
199
+ * and enable the bash-write collector.
200
+ * @param deps - test seam for the native reveal dispatch.
201
+ */
202
+ constructor(ctx, config = {}, deps = {}) {
203
+ super(ctx, 'filePreview');
204
+ this.resolved = {
205
+ maxReadBytes: config.maxReadBytes ?? 512 * 1024,
206
+ htmlMaxReadBytes: config.htmlMaxReadBytes ?? 4 * 1024 * 1024,
207
+ maxFiles: config.maxFiles ?? 500,
208
+ captureBashWrites: config.captureBashWrites ?? true,
209
+ };
210
+ this.revealNative = deps.revealNative ?? revealNativePath;
211
+ if (config.captureBashWrites !== false) {
212
+ this.collector = new BashWriteCollector({ fs: this.fs, maxFiles: this.resolved.maxFiles });
213
+ ctx.effect(() => this.collector.attach(ctx), 'file-preview: bash-write capture');
214
+ }
215
+ else {
216
+ this.collector = undefined;
217
+ }
218
+ const webServer = ctx.get('webServer');
219
+ this.agents = ctx.get('agents');
220
+ this.imageRoute = webServer !== undefined && this.agents !== undefined;
221
+ if (this.imageRoute && webServer !== undefined) {
222
+ ctx.effect(() => webServer.register({
223
+ kind: 'prefix',
224
+ path: FilePreviewService.IMAGE_ROUTE,
225
+ /* v8 ignore next 2 -- the handler body is the request path; the
226
+ unit tests drive serveImage directly, the real server reaches it
227
+ through this registration */
228
+ handler: (req, res) => { void this.serveImage(req, res); },
229
+ }), 'file-preview: image route');
230
+ }
231
+ registerArtifactSkill(ctx);
232
+ }
233
+ get fs() {
234
+ return this.ctx.fs;
235
+ }
236
+ /**
237
+ * Serve one image file's bytes for a browser `<img>` request. The URL is
238
+ * `IMAGE_ROUTE/<sessionId>/<encodeURIComponent(path)>`; the session must be
239
+ * live (the same scope `read` serves) and the path resolves against its cwd.
240
+ * @param req - the incoming image request.
241
+ * @param res - the response to fill with bytes or an error status.
242
+ */
243
+ async serveImage(req, res) {
244
+ /* v8 ignore next -- split('?')[0] is always a string when url is defined; the fallback guards a future parse change */
245
+ const pathname = req.url === undefined ? '' : req.url.split('?')[0] ?? '';
246
+ const rest = pathname.slice(FilePreviewService.IMAGE_ROUTE.length + 1);
247
+ const slash = rest.indexOf('/');
248
+ if (slash <= 0) {
249
+ res.writeHead(400).end('file preview: expected /<sessionId>/<path>');
250
+ return;
251
+ }
252
+ const sessionId = rest.slice(0, slash);
253
+ const encoded = rest.slice(slash + 1);
254
+ let display;
255
+ try {
256
+ display = decodeURIComponent(encoded);
257
+ }
258
+ catch {
259
+ res.writeHead(400).end('file preview: malformed path');
260
+ return;
261
+ }
262
+ const agent = this.agents?.get(sessionId);
263
+ if (agent === undefined) {
264
+ res.writeHead(404).end('file preview: session not found');
265
+ return;
266
+ }
267
+ if (!isImagePath(display)) {
268
+ res.writeHead(400).end('file preview: not an image path');
269
+ return;
270
+ }
271
+ const cwd = agent.session.header.cwd;
272
+ let target;
273
+ try {
274
+ target = await this.fs.resolve(display, cwd === undefined ? {} : { cwd });
275
+ }
276
+ catch (error) {
277
+ res.writeHead(404).end(readErrorMessage(error));
278
+ return;
279
+ }
280
+ let info;
281
+ try {
282
+ info = await this.fs.stat(target);
283
+ }
284
+ catch (error) {
285
+ res.writeHead(404).end(readErrorMessage(error));
286
+ return;
287
+ }
288
+ if (info === undefined || info.type !== 'file') {
289
+ res.writeHead(404).end('file preview: not a file');
290
+ return;
291
+ }
292
+ if (info.size !== undefined && info.size > this.resolved.maxReadBytes) {
293
+ res.writeHead(413).end('file preview: image exceeds the read cap');
294
+ return;
295
+ }
296
+ let bytes;
297
+ try {
298
+ bytes = await this.fs.readBytes(target, undefined, this.resolved.maxReadBytes);
299
+ }
300
+ catch (error) {
301
+ res.writeHead(404).end(readErrorMessage(error));
302
+ return;
303
+ }
304
+ const dot = display.lastIndexOf('.');
305
+ /* v8 ignore next -- every IMAGE_EXTENSIONS entry has a MIME mapping; the fallback guards a future extension */
306
+ const mime = IMAGE_MIME[display.slice(dot).toLowerCase()] ?? 'application/octet-stream';
307
+ res.writeHead(200, {
308
+ 'Content-Type': mime,
309
+ 'Cache-Control': 'no-store',
310
+ });
311
+ res.end(Buffer.from(bytes));
312
+ }
313
+ /**
314
+ * List the files one session wrote or edited: the log fold plus any
315
+ * bash-written files the collector verified (the S2 seam), merged so a
316
+ * captured path the fold already knows keeps its log-derived entry.
317
+ * @param agent - owning live agent; its session log is the data source.
318
+ * @returns first-seen files capped by `maxFiles`, with the log watermark.
319
+ */
320
+ list(agent) {
321
+ const folded = foldFilePreview(agent.session.events, this.resolved.maxFiles);
322
+ const captured = this.collector?.captured(agent.session.id);
323
+ if (captured === undefined || captured.size === 0)
324
+ return folded;
325
+ const seen = new Set(folded.entries.map(entry => entry.path));
326
+ const extra = [];
327
+ for (const [path, write] of captured) {
328
+ if (seen.has(path) || folded.entries.length + extra.length >= this.resolved.maxFiles)
329
+ continue;
330
+ extra.push({ path, op: 'write', seq: write.seq, turn: write.turn, step: write.step, diffs: [] });
331
+ }
332
+ if (extra.length === 0)
333
+ return folded;
334
+ return {
335
+ entries: [...folded.entries, ...extra],
336
+ asOfSeq: folded.asOfSeq,
337
+ truncated: folded.truncated || folded.entries.length + extra.length >= this.resolved.maxFiles,
338
+ };
339
+ }
340
+ /**
341
+ * Every turn's file mutations for the turn-tail card — the single source of
342
+ * truth the card renders (the client's old write/edit-only fold retired).
343
+ * Built by folding the session log per turn (write/edit calls, Code Mode
344
+ * dispatches borrowing the root call's turn, and render-intent paths from
345
+ * result diff meta) and merging the collector's bash-written captures by
346
+ * their own turn. The fold is cached per session and invalidated by the log
347
+ * watermark, so repeated card fetches do not refold. A path touched in two
348
+ * turns appears in BOTH turn groups — per-turn attribution is exact.
349
+ * @param agent - owning live agent; its session log is the data source.
350
+ * @returns per-turn file groups plus the scanned watermark.
351
+ */
352
+ turnFiles(agent) {
353
+ const session = agent.session;
354
+ const events = session.events;
355
+ const asOfSeq = events.length === 0 ? -1 : events[events.length - 1].seq;
356
+ const cached = this.turnCache.get(session.id);
357
+ let byTurn;
358
+ if (cached === undefined || cached.asOfSeq !== asOfSeq) {
359
+ byTurn = foldFilePreviewByTurn(events);
360
+ this.turnCache.set(session.id, { asOfSeq, byTurn });
361
+ }
362
+ else {
363
+ byTurn = cached.byTurn;
364
+ }
365
+ const captured = this.collector?.captured(session.id);
366
+ const turns = [];
367
+ // One pass over the turn keys in ascending order (turn numbers are small).
368
+ const turnNumbers = new Set(byTurn.keys());
369
+ if (captured !== undefined) {
370
+ for (const write of captured.values())
371
+ turnNumbers.add(write.turn);
372
+ }
373
+ for (const turn of [...turnNumbers].sort((a, b) => a - b)) {
374
+ const files = [];
375
+ const map = byTurn.get(turn);
376
+ if (map !== undefined)
377
+ files.push(...map.values());
378
+ if (captured !== undefined) {
379
+ for (const [path, write] of captured) {
380
+ if (write.turn !== turn || files.some(file => file.path === path))
381
+ continue;
382
+ files.push({ path, seq: write.seq, step: write.step });
383
+ }
384
+ }
385
+ if (files.length === 0)
386
+ continue;
387
+ turns.push({ turn, files: files.sort((a, b) => a.seq - b.seq) });
388
+ }
389
+ return { turns, asOfSeq };
390
+ }
391
+ /**
392
+ * Read the current content of one recorded file, resolved against the
393
+ * session cwd and capped by `maxReadBytes`.
394
+ * @param agent - owning live agent; its session cwd anchors relative paths.
395
+ * @param path - the display path recorded by the write/edit tool call.
396
+ * @param signal - cooperative cancellation from the calling UI request.
397
+ * @returns the classified read; `text` carries content, `image` carries a
398
+ * browser URL, other kinds carry the size or a human-readable reason.
399
+ */
400
+ async read(agent, path, signal) {
401
+ if (typeof path !== 'string' || path.length === 0) {
402
+ return { path, kind: 'error', message: 'file preview requires a non-empty path' };
403
+ }
404
+ const cwd = agent.session.header.cwd;
405
+ let target;
406
+ try {
407
+ target = await this.fs.resolve(path, cwd === undefined ? { signal } : { cwd, signal });
408
+ }
409
+ catch (error) {
410
+ return { path, kind: 'error', message: readErrorMessage(error) };
411
+ }
412
+ let info;
413
+ try {
414
+ info = await this.fs.stat(target, signal);
415
+ }
416
+ catch (error) {
417
+ return { path, kind: 'error', message: readErrorMessage(error) };
418
+ }
419
+ if (info === undefined || info.type !== 'file') {
420
+ return { path, kind: 'missing' };
421
+ }
422
+ // HTML gets its own, wider render-channel cap; every other text read
423
+ // keeps `maxReadBytes`. Images keep the base cap (their route serves
424
+ // bytes, not the render channel).
425
+ const html = isHtmlPath(path);
426
+ const cap = html ? this.resolved.htmlMaxReadBytes : this.resolved.maxReadBytes;
427
+ if (info.size !== undefined && info.size > cap) {
428
+ return { path, kind: 'too-large', size: info.size };
429
+ }
430
+ if (isImagePath(path)) {
431
+ if (this.imageRoute) {
432
+ const encoded = encodeURIComponent(path);
433
+ return {
434
+ path,
435
+ kind: 'image',
436
+ url: `${FilePreviewService.IMAGE_ROUTE}/${agent.session.id}/${encoded}`,
437
+ ...(info.size === undefined ? {} : { size: info.size }),
438
+ };
439
+ }
440
+ // No web face to serve bytes: an image is binary, never decoded as text.
441
+ return { path, kind: 'binary', ...(info.size === undefined ? {} : { size: info.size }) };
442
+ }
443
+ if (isBinaryPath(path)) {
444
+ return { path, kind: 'binary', ...(info.size === undefined ? {} : { size: info.size }) };
445
+ }
446
+ try {
447
+ const content = await this.fs.readText(target, signal);
448
+ if (hasNulByte(content)) {
449
+ return { path, kind: 'binary', ...(info.size === undefined ? {} : { size: info.size }) };
450
+ }
451
+ const truncated = content.length > cap;
452
+ return {
453
+ path,
454
+ kind: 'text',
455
+ content: truncated ? content.slice(0, cap) : content,
456
+ truncated,
457
+ ...(html && isScriptedHtml(content) ? { htmlScripted: true } : {}),
458
+ ...(info.size === undefined ? {} : { size: info.size }),
459
+ };
460
+ }
461
+ catch (error) {
462
+ return { path, kind: 'error', message: readErrorMessage(error) };
463
+ }
464
+ }
465
+ /**
466
+ * Reveal one recorded file in the host file manager — open its folder and
467
+ * select the file (macOS Finder, Windows Explorer, or a select-capable
468
+ * desktop file manager). The display path resolves against the session cwd;
469
+ * the native reveal runs on the host. A path that does not resolve to an
470
+ * existing target, or a platform that cannot select, answers `revealed:
471
+ * false` and the caller opens the parent folder instead (the pre-reveal
472
+ * "show in folder" behavior), so the gesture always lands somewhere visible.
473
+ * @param agent - owning live agent; its session cwd anchors relative paths.
474
+ * @param path - the display path recorded by the read/write/edit tool call.
475
+ * @param signal - cooperative cancellation from the calling UI request.
476
+ * @returns whether the file manager selected the file, or why not.
477
+ */
478
+ async reveal(agent, path, signal) {
479
+ if (typeof path !== 'string' || path.length === 0) {
480
+ return { revealed: false, reason: 'missing' };
481
+ }
482
+ const cwd = agent.session.header.cwd;
483
+ let target;
484
+ try {
485
+ target = await this.fs.resolve(path, cwd === undefined ? { signal } : { cwd, signal });
486
+ }
487
+ catch (error) {
488
+ // Unresolvable recorded path: the file is gone; the caller opens the parent.
489
+ return { revealed: false, reason: 'missing' };
490
+ }
491
+ let info;
492
+ try {
493
+ info = await this.fs.stat(target, signal);
494
+ }
495
+ catch {
496
+ // A stat failure is treated as missing; the caller still opens the parent.
497
+ info = undefined;
498
+ }
499
+ if (info === undefined) {
500
+ return { revealed: false, reason: 'missing' };
501
+ }
502
+ try {
503
+ // processPath hands the canonical path a host subprocess can open; the
504
+ // recorded display path may be relative or backend-normalized.
505
+ await this.revealNative(this.fs.processPath(target), signal);
506
+ return { revealed: true };
507
+ }
508
+ catch {
509
+ // No select-capable file manager (or a failed launch): fall back to the
510
+ // parent-folder open rather than failing the whole gesture.
511
+ return { revealed: false, reason: 'select-failed' };
512
+ }
513
+ }
514
+ };
515
+ })();
516
+ export { FilePreviewService };
517
+ export default FilePreviewService;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Package-owned invariant companion for `@khorsheed/dsh-file-preview`.
3
+ * @module @khorsheed/dsh-file-preview/invariant
4
+ */
5
+ import type { Context } from '@deepseek-ai/cordis';
6
+ /** Cordis companion plugin name. */
7
+ export declare const name = "file-preview-invariant";
8
+ /** Service required before the companion can reserve package ownership. */
9
+ export declare const inject: string[];
10
+ /**
11
+ * Register this package's invariant companion.
12
+ * @param ctx - Cordis context carrying the invariant service.
13
+ * @returns the installed registration's disposer after setup succeeds.
14
+ */
15
+ export declare const apply: (ctx: Context) => Promise<() => void>;
16
+ //# sourceMappingURL=invariant.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"invariant.d.ts","sourceRoot":"","sources":["../../src/invariant.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAKlD,oCAAoC;AACpC,eAAO,MAAM,IAAI,2BAA2B,CAAA;AAC5C,2EAA2E;AAC3E,eAAO,MAAM,MAAM,UAAiB,CAAA;AAUpC;;;;GAIG;AACH,eAAO,MAAM,KAAK,QAAS,OAAO,KAAG,OAAO,CAAC,MAAM,IAAI,CACU,CAAA"}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Package-owned invariant companion for `@khorsheed/dsh-file-preview`.
3
+ * @module @khorsheed/dsh-file-preview/invariant
4
+ */
5
+ const PACKAGE_NAME = '@khorsheed/dsh-file-preview';
6
+ /** Cordis companion plugin name. */
7
+ export const name = 'file-preview-invariant';
8
+ /** Service required before the companion can reserve package ownership. */
9
+ export const inject = ['invariants'];
10
+ /**
11
+ * No runtime invariant: the service owns no durable package-local state and
12
+ * writes nothing — `list` is a pure deterministic fold over the session log
13
+ * (unit-tested), and `read` re-reads the filesystem through `ctx.fs`, whose
14
+ * own invariants own that relationship.
15
+ */
16
+ const install = () => { };
17
+ /**
18
+ * Register this package's invariant companion.
19
+ * @param ctx - Cordis context carrying the invariant service.
20
+ * @returns the installed registration's disposer after setup succeeds.
21
+ */
22
+ export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
23
+ /* jscpd:ignore-end */
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Cross-platform native "show in folder, selecting the file" dispatch used by
3
+ * the file-preview Remote's `reveal` method.
4
+ *
5
+ * The official host opener only opens a path with the default application —
6
+ * it has no select-in-folder intent — so this plugin carries its own
7
+ * shell-free dispatch: macOS `open -R` (Finder reveals and selects), Windows
8
+ * `explorer /select,<path>` (Explorer reveals and selects), WSL translates the
9
+ * path to the Windows spelling first, and desktop Linux tries the
10
+ * select-capable file managers (`nautilus` / `dolphin` / `nemo` `--select`) in
11
+ * order, falling back to the caller's parent-folder open when none is
12
+ * installed. Every command runs through the official
13
+ * `@deepseek-ai/dsh-native-command` runner — no shell, abort propagation.
14
+ * @module @khorsheed/dsh-file-preview
15
+ */
16
+ import { type NativeCommandRunner } from '@deepseek-ai/dsh-native-command';
17
+ /** Injectable platform facts for deterministic adapter tests. */
18
+ export interface RevealInternals {
19
+ platform?: NodeJS.Platform;
20
+ /** Kernel release override used to distinguish WSL from desktop Linux. */
21
+ osRelease?: string;
22
+ /** Environment used for the WSL markers. */
23
+ env?: NodeJS.ProcessEnv;
24
+ run?: NativeCommandRunner;
25
+ }
26
+ /**
27
+ * Reveal one path in the host file manager, opening its folder and selecting
28
+ * the path. Throws when the platform cannot select (no select-capable file
29
+ * manager, unsupported platform) — the caller then opens the parent folder.
30
+ * @param path - absolute path in the host's execution world (caller owns
31
+ * resolution, typically `FileSystem.processPath`).
32
+ * @param signal - caller/connection lifetime; abort terminates the native command.
33
+ * @param internals - platform and environment seam for deterministic tests.
34
+ */
35
+ export declare function revealNativePath(path: string, signal: AbortSignal, internals?: RevealInternals): Promise<void>;
36
+ //# sourceMappingURL=reveal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reveal.d.ts","sourceRoot":"","sources":["../../src/reveal.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,EAAoB,KAAK,mBAAmB,EAAE,MAAM,iCAAiC,CAAA;AAE5F,iEAAiE;AACjE,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAA;IAC1B,0EAA0E;IAC1E,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,4CAA4C;IAC5C,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAA;IACvB,GAAG,CAAC,EAAE,mBAAmB,CAAA;CAC1B;AAiBD;;;;;;;;GAQG;AACH,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,WAAW,EACnB,SAAS,GAAE,eAAoB,GAC9B,OAAO,CAAC,IAAI,CAAC,CAoCf"}