@khorsheed/dsh-file-preview 0.1.1 → 0.3.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.
package/lib/index.js CHANGED
@@ -1,9 +1,7 @@
1
- import { readFileSync } from "node:fs";
2
- import { dirname, join, resolve } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
1
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
5
2
  import z from "@deepseek-ai/schemastery";
6
3
  import { homedir, release } from "node:os";
4
+ import { resolve } from "node:path";
7
5
  import { runNativeCommand } from "@deepseek-ai/dsh-native-command";
8
6
  //#region lib/types/bash-writes.js
9
7
  /**
@@ -271,7 +269,7 @@ var BashWriteCollector = class {
271
269
  #replay(session) {
272
270
  this.#pending.set(session.id, /* @__PURE__ */ new Map());
273
271
  this.#captured.set(session.id, /* @__PURE__ */ new Map());
274
- for (const event of session.events) this.#onEvent(session, event);
272
+ for (const event of session.snapshotEvents()) this.#onEvent(session, event);
275
273
  }
276
274
  #onEvent(session, event) {
277
275
  if (event.type === "tool/call") {
@@ -381,9 +379,11 @@ function diffsFromResultMeta(meta) {
381
379
  * first-seen order and deduplicated by display path; a later occurrence
382
380
  * refreshes the entry's op and location without moving it, and each write/edit
383
381
  * tool result appends its change's diff to the entry's `diffs` in event order.
384
- * Nested Code Mode file dispatches (`tool/code-dispatch` events) count too —
385
- * they carry no turn/step of their own, so the entry borrows the enclosing
386
- * root call's location.
382
+ * Nested PTC file dispatches (`tool/ptc-dispatch` events — the host's Code
383
+ * Mode name through 0.1.2, renamed with the 0.1.5 format; the v2→v3 log
384
+ * migration renames persisted rows, so only the ptc name is matched) count
385
+ * too — they carry no turn/step of their own, so the entry borrows the
386
+ * enclosing root call's location.
387
387
  * @param events - the session's events in ascending seq order.
388
388
  * @param maxFiles - cap on returned entries; excess files are dropped in
389
389
  * first-seen order after the cap is reached.
@@ -391,7 +391,7 @@ function diffsFromResultMeta(meta) {
391
391
  */
392
392
  function foldFilePreview(events, maxFiles) {
393
393
  const entries = /* @__PURE__ */ new Map();
394
- /** Root tool/call locations, so nested code dispatches borrow their turn/step. */
394
+ /** Root tool/call locations, so nested dispatches borrow their turn/step. */
395
395
  const callSites = /* @__PURE__ */ new Map();
396
396
  let asOfSeq = -1;
397
397
  /** Upsert one touched path, refreshing a repeat in place and keeping its diffs. */
@@ -424,7 +424,7 @@ function foldFilePreview(events, maxFiles) {
424
424
  record(target, event.seq, event.data.turn, event.data.step);
425
425
  continue;
426
426
  }
427
- if (event.type === "tool/code-dispatch") {
427
+ if (event.type === "tool/ptc-dispatch") {
428
428
  if (event.data.isError) continue;
429
429
  const target = targetFromArguments(event.data.name, event.data.arguments);
430
430
  if (target === void 0) continue;
@@ -508,11 +508,12 @@ function resultCallFacts(message) {
508
508
  * single source of truth. Unlike {@link foldFilePreview}, this does NOT dedupe
509
509
  * a path to its last occurrence: a file touched in two turns appears in BOTH
510
510
  * turn maps, so each turn's card lists exactly what that turn mutated.
511
- * write/edit calls register with their own turn; nested Code Mode dispatches
512
- * borrow the enclosing root call's turn (dispatch events carry none); a
513
- * `tool/result` whose presentation meta carries diffs registers its paths (the
514
- * render-intent vocabulary) with the result's turn, and per-turn line deltas
515
- * are summed from the diffs. A `write` whose result carries no diffs is a
511
+ * Reads leave no card entry (the card is the mutation vocabulary — write/edit
512
+ * only), so a pure-read call never registers; write/edit calls register with
513
+ * their own turn; nested PTC dispatches borrow the enclosing root call's
514
+ * turn (dispatch events carry none); a `tool/result` whose presentation meta
515
+ * carries diffs registers its paths (the render-intent vocabulary) with the
516
+ * result's turn, and per-turn line deltas are summed from the diffs. A `write` whose result carries no diffs is a
516
517
  * create (the write tool records no diffs when the file had no prior content):
517
518
  * its full written content — read from the call arguments, which the log keeps
518
519
  * — is the added-line count, once per file per turn.
@@ -520,7 +521,7 @@ function resultCallFacts(message) {
520
521
  * Count discipline: a write/edit tool/call only registers the path's location
521
522
  * and never touches counts (its counted result follows); the first count
522
523
  * contribution for a turn+path is adopted verbatim, later ones accumulate per
523
- * field with unknown-wins — an uncounted mutation (a Code Mode dispatch, or a
524
+ * field with unknown-wins — an uncounted mutation (a PTC dispatch, or a
524
525
  * diff whose side reports no prior content) poisons that field to undefined
525
526
  * rather than reporting a misleading partial.
526
527
  * @param events - the session's events in ascending seq order.
@@ -528,7 +529,7 @@ function resultCallFacts(message) {
528
529
  */
529
530
  function foldFilePreviewByTurn(events) {
530
531
  const byTurn = /* @__PURE__ */ new Map();
531
- /** Root tool/call locations, so nested code dispatches borrow their turn/step. */
532
+ /** Root tool/call locations, so nested dispatches borrow their turn/step. */
532
533
  const callSites = /* @__PURE__ */ new Map();
533
534
  /** Write-tool calls keyed by callId (create fallback; see {@link WriteCallFacts}). */
534
535
  const writeCalls = /* @__PURE__ */ new Map();
@@ -566,7 +567,7 @@ function foldFilePreviewByTurn(events) {
566
567
  * a turn+path is adopted verbatim (a count-less placeholder from a
567
568
  * preceding tool/call must not poison it); later contributions accumulate
568
569
  * per field with unknown-wins. `unknown` marks an uncountable mutation (a
569
- * Code Mode dispatch): it poisons both fields. */
570
+ * PTC dispatch): it poisons both fields. */
570
571
  const recordContribution = (path, turn, seq, step, added, removed, unknown) => {
571
572
  const map = turnMap(turn);
572
573
  const key = countKey(turn, path);
@@ -609,6 +610,7 @@ function foldFilePreviewByTurn(events) {
609
610
  });
610
611
  const target = pathFromToolCall(event.data.name, event.data.arguments);
611
612
  if (target === void 0) continue;
613
+ if (target.op === "read") continue;
612
614
  if (target.op === "write") {
613
615
  const content = contentFromWriteCall(event.data.arguments);
614
616
  if (content !== void 0) {
@@ -624,10 +626,11 @@ function foldFilePreviewByTurn(events) {
624
626
  recordLocation(target.path, event.data.turn, event.seq, event.data.step);
625
627
  continue;
626
628
  }
627
- if (event.type === "tool/code-dispatch") {
629
+ if (event.type === "tool/ptc-dispatch") {
628
630
  if (event.data.isError) continue;
629
631
  const target = targetFromArguments(event.data.name, event.data.arguments);
630
632
  if (target === void 0) continue;
633
+ if (target.op === "read") continue;
631
634
  const site = callSites.get(String(event.data.rootCallId)) ?? {
632
635
  turn: 0,
633
636
  step: 0
@@ -654,6 +657,68 @@ function foldFilePreviewByTurn(events) {
654
657
  return byTurn;
655
658
  }
656
659
  //#endregion
660
+ //#region lib/types/open-external.js
661
+ /**
662
+ * Native "open this file in a specific application" dispatch used by the
663
+ * file-preview Remote's `openExternal` method (the "open in IDE" gesture).
664
+ *
665
+ * The official open-in-app route (`POST /open-in-app/open`) only accepts
666
+ * directories — a file path 404s — so file-exact opens carry their own
667
+ * dispatch here. macOS only: `open -a <App> <path>` through the official
668
+ * `@deepseek-ai/dsh-native-command` runner (no shell, abort propagation),
669
+ * mirroring `reveal.ts`'s discipline. The browser picks the application by
670
+ * official open-in-app catalog id (probed from `/open-in-app/apps`); the
671
+ * id → `.app` name map below mirrors the official catalog's darwin entries
672
+ * (packages/host/open-in-app/src/catalog.ts at 0.1.5-rc.1 — re-check on every
673
+ * host upgrade). Other platforms and unknown ids answer unsupported, and the
674
+ * client hides the gesture.
675
+ * @module @khorsheed/dsh-file-preview
676
+ */
677
+ /** Official catalog id → macOS application name (darwin entries of the official catalog). */
678
+ const MAC_APP_NAMES = {
679
+ cursor: "Cursor",
680
+ vscode: "Visual Studio Code",
681
+ vscodeinsiders: "Visual Studio Code - Insiders",
682
+ windsurf: "Windsurf",
683
+ zed: "Zed",
684
+ sublimetext: "Sublime Text",
685
+ xcode: "Xcode",
686
+ androidstudio: "Android Studio",
687
+ intellij: "IntelliJ IDEA",
688
+ pycharm: "PyCharm",
689
+ webstorm: "WebStorm",
690
+ phpstorm: "PhpStorm",
691
+ goland: "GoLand",
692
+ rider: "Rider",
693
+ rustrover: "RustRover"
694
+ };
695
+ /** The application name one catalog id maps to, or undefined when unknown. */
696
+ function macAppName(appId) {
697
+ return MAC_APP_NAMES[appId];
698
+ }
699
+ /**
700
+ * Open one file in the named application. Throws when the platform is not
701
+ * macOS or the catalog id is unknown — the caller maps both to an
702
+ * `unsupported`/`unknown-app` answer and the client hides the gesture. A
703
+ * launch failure propagates as the runner's rejection.
704
+ * @param path - absolute path in the host's execution world (caller owns
705
+ * resolution, typically `FileSystem.processPath`).
706
+ * @param appId - official open-in-app catalog id (e.g. `cursor`).
707
+ * @param signal - caller/connection lifetime; abort terminates the native command.
708
+ * @param internals - platform seam for deterministic tests.
709
+ */
710
+ async function openExternalNative(path, appId, signal, internals = {}) {
711
+ const platform = internals.platform ?? process.platform;
712
+ if (platform !== "darwin") throw new Error(`open-in-app is unsupported on ${platform}`);
713
+ const app = macAppName(appId);
714
+ if (app === void 0) throw new Error(`unknown application id "${appId}"`);
715
+ await (internals.run ?? runNativeCommand)("open", [
716
+ "-a",
717
+ app,
718
+ path
719
+ ], signal);
720
+ }
721
+ //#endregion
657
722
  //#region lib/types/reveal.js
658
723
  /**
659
724
  * Cross-platform native "show in folder, selecting the file" dispatch used by
@@ -771,45 +836,6 @@ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializ
771
836
  if (target) Object.defineProperty(target, contextIn.name, descriptor);
772
837
  done = true;
773
838
  };
774
- /**
775
- * Register the 3d-artifact skill — the generation-side contract for
776
- * sandbox-runnable interactive 3D / digital-twin single-file HTML (self-
777
- * contained, zero runtime network, GLB-inline zero-fetch models). Pull-based
778
- * discovery: an agent whose task involves generating such an HTML page finds
779
- * the contract through the skill catalog — no per-session push notice.
780
- * Optional: compositions without the skill capability skip the registration.
781
- * A missing/malformed shipped SKILL.md degrades to a warning — a discovery
782
- * aid must never take a boot down; the pack-smoke test owns the file's
783
- * presence in the tarball.
784
- * @param ctx - plugin context.
785
- */
786
- function registerArtifactSkill(ctx) {
787
- const skills = ctx.get("skills");
788
- if (skills === void 0) {
789
- ctx.logger.warn("file-preview: skills capability absent — the 3d-artifact skill is not registered");
790
- return;
791
- }
792
- try {
793
- const skillFile = join(dirname(fileURLToPath(import.meta.url)), "..", "skills", "3d-artifact", "SKILL.md");
794
- const raw = readFileSync(skillFile, "utf8");
795
- const match = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(raw);
796
- const name = /^name: (.+)$/m.exec(match?.[1] ?? "")?.[1]?.trim();
797
- const description = /^description: (.+)$/m.exec(match?.[1] ?? "")?.[1]?.trim();
798
- const content = match?.[2];
799
- if (match === null || name === void 0 || description === void 0 || content === void 0) {
800
- ctx.logger.warn("file-preview: shipped SKILL.md is malformed — the 3d-artifact skill is not registered");
801
- return;
802
- }
803
- ctx.effect(() => skills.register({
804
- name,
805
- description,
806
- content,
807
- source: "runtime"
808
- }));
809
- } catch (error) {
810
- ctx.logger.warn(`file-preview: shipped SKILL.md unreadable (${String(error)}) — the 3d-artifact skill is not registered`);
811
- }
812
- }
813
839
  /** Extensions treated as binary without reading (their text decode is meaningless). */
814
840
  const BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
815
841
  ".pdf",
@@ -900,8 +926,9 @@ function readErrorMessage(error) {
900
926
  }
901
927
  /**
902
928
  * The `filePreview` Remote service: session-scoped read-only file inspection
903
- * for web surfaces. `list` is a pure fold over the session log (no filesystem
904
- * access); `read` resolves the path against the session cwd and serves current
929
+ * for web surfaces. `list` is a fold over the session log, then filtered to the
930
+ * paths that still exist on disk (resolved against the session cwd and stat-ed
931
+ * per call); `read` resolves the path against the session cwd and serves current
905
932
  * text content through `ctx.fs` (or a browser URL for images), capped by
906
933
  * config. Image bytes ride a dedicated host route so the browser loads them
907
934
  * natively without bloating the RPC channel.
@@ -909,17 +936,32 @@ function readErrorMessage(error) {
909
936
  let FilePreviewService = (() => {
910
937
  let _classSuper = TypertRemoteService;
911
938
  let _instanceExtraInitializers = [];
939
+ let _capabilities_decorators;
912
940
  let _list_decorators;
913
941
  let _turnFiles_decorators;
914
942
  let _read_decorators;
915
943
  let _reveal_decorators;
944
+ let _openExternal_decorators;
916
945
  return class FilePreviewService extends _classSuper {
917
946
  static {
918
947
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
948
+ _capabilities_decorators = [Remote("capabilities")];
919
949
  _list_decorators = [Remote("list")];
920
950
  _turnFiles_decorators = [Remote("turnFiles")];
921
951
  _read_decorators = [Remote("read")];
922
952
  _reveal_decorators = [Remote("reveal")];
953
+ _openExternal_decorators = [Remote("openExternal")];
954
+ __esDecorate(this, null, _capabilities_decorators, {
955
+ kind: "method",
956
+ name: "capabilities",
957
+ static: false,
958
+ private: false,
959
+ access: {
960
+ has: (obj) => "capabilities" in obj,
961
+ get: (obj) => obj.capabilities
962
+ },
963
+ metadata: _metadata
964
+ }, null, _instanceExtraInitializers);
923
965
  __esDecorate(this, null, _list_decorators, {
924
966
  kind: "method",
925
967
  name: "list",
@@ -964,6 +1006,17 @@ let FilePreviewService = (() => {
964
1006
  },
965
1007
  metadata: _metadata
966
1008
  }, null, _instanceExtraInitializers);
1009
+ __esDecorate(this, null, _openExternal_decorators, {
1010
+ kind: "method",
1011
+ name: "openExternal",
1012
+ static: false,
1013
+ private: false,
1014
+ access: {
1015
+ has: (obj) => "openExternal" in obj,
1016
+ get: (obj) => obj.openExternal
1017
+ },
1018
+ metadata: _metadata
1019
+ }, null, _instanceExtraInitializers);
967
1020
  if (_metadata) Object.defineProperty(this, Symbol.metadata, {
968
1021
  enumerable: true,
969
1022
  configurable: true,
@@ -988,6 +1041,8 @@ let FilePreviewService = (() => {
988
1041
  agents;
989
1042
  /** Native reveal runner; a constructor seam for deterministic tests. */
990
1043
  revealNative;
1044
+ /** Native open-in-app runner; a constructor seam for deterministic tests. */
1045
+ openExternalNative;
991
1046
  /** Bash-write collector (S2 seam), or undefined when disabled in config. */
992
1047
  collector;
993
1048
  /** Per-session by-turn fold cache, invalidated by the log watermark. */
@@ -1008,6 +1063,7 @@ let FilePreviewService = (() => {
1008
1063
  captureBashWrites: config.captureBashWrites ?? true
1009
1064
  };
1010
1065
  this.revealNative = deps.revealNative ?? revealNativePath;
1066
+ this.openExternalNative = deps.openExternalNative ?? openExternalNative;
1011
1067
  if (config.captureBashWrites !== false) {
1012
1068
  this.collector = new BashWriteCollector({
1013
1069
  fs: this.fs,
@@ -1028,7 +1084,10 @@ let FilePreviewService = (() => {
1028
1084
  this.serveImage(req, res);
1029
1085
  }
1030
1086
  }), "file-preview: image route");
1031
- registerArtifactSkill(ctx);
1087
+ }
1088
+ /** Zero-session availability handshake for browser companions. */
1089
+ capabilities() {
1090
+ return { protocolVersion: 1 };
1032
1091
  }
1033
1092
  get fs() {
1034
1093
  return this.ctx.fs;
@@ -1104,52 +1163,84 @@ let FilePreviewService = (() => {
1104
1163
  });
1105
1164
  res.end(Buffer.from(bytes));
1106
1165
  }
1166
+ /** Resolve the session's recorded display paths against its cwd and keep only
1167
+ * those that currently exist as a regular file. The products surfaces fold
1168
+ * the session log (which is history, not disk state): a path the log wrote
1169
+ * may have been cleaned up since (a temp script the turn deleted), and such
1170
+ * a path is not a product to list. Existence is checked fresh on every call
1171
+ * (never cached), so a deletion that lands after a fold is visible on the
1172
+ * next fetch. Unresolvable/unstat-able paths are treated as absent.
1173
+ * @param agent - owning live agent; its session cwd anchors relative paths.
1174
+ * @param paths - the display paths to probe.
1175
+ * @returns the subset that resolves to an existing regular file.
1176
+ */
1177
+ async retainExisting(agent, paths) {
1178
+ if (paths.length === 0) return /* @__PURE__ */ new Set();
1179
+ const cwd = agent.session.header.cwd;
1180
+ const existing = /* @__PURE__ */ new Set();
1181
+ for (const path of paths) {
1182
+ if (existing.has(path)) continue;
1183
+ try {
1184
+ const target = await this.fs.resolve(path, cwd === void 0 ? {} : { cwd });
1185
+ if ((await this.fs.stat(target))?.type === "file") existing.add(path);
1186
+ } catch {}
1187
+ }
1188
+ return existing;
1189
+ }
1107
1190
  /**
1108
1191
  * List the files one session wrote or edited: the log fold plus any
1109
1192
  * bash-written files the collector verified (the S2 seam), merged so a
1110
- * captured path the fold already knows keeps its log-derived entry.
1193
+ * captured path the fold already knows keeps its log-derived entry. Only
1194
+ * paths that currently exist on disk are returned — a file the session
1195
+ * wrote and later deleted is history, not a product.
1111
1196
  * @param agent - owning live agent; its session log is the data source.
1112
- * @returns first-seen files capped by `maxFiles`, with the log watermark.
1197
+ * @returns existing first-seen files capped by `maxFiles`, with the log watermark.
1113
1198
  */
1114
- list(agent) {
1115
- const folded = foldFilePreview(agent.session.events, this.resolved.maxFiles);
1199
+ async list(agent) {
1200
+ const folded = foldFilePreview(agent.session.snapshotEvents(), this.resolved.maxFiles);
1116
1201
  const captured = this.collector?.captured(agent.session.id);
1117
- if (captured === void 0 || captured.size === 0) return folded;
1118
- const seen = new Set(folded.entries.map((entry) => entry.path));
1119
- const extra = [];
1120
- for (const [path, write] of captured) {
1121
- if (seen.has(path) || folded.entries.length + extra.length >= this.resolved.maxFiles) continue;
1122
- extra.push({
1123
- path,
1124
- op: "write",
1125
- seq: write.seq,
1126
- turn: write.turn,
1127
- step: write.step,
1128
- diffs: []
1129
- });
1202
+ let entries = folded.entries;
1203
+ if (captured !== void 0 && captured.size > 0) {
1204
+ const seen = new Set(entries.map((entry) => entry.path));
1205
+ const extra = [];
1206
+ for (const [path, write] of captured) {
1207
+ if (seen.has(path) || entries.length + extra.length >= this.resolved.maxFiles) continue;
1208
+ extra.push({
1209
+ path,
1210
+ op: "write",
1211
+ seq: write.seq,
1212
+ turn: write.turn,
1213
+ step: write.step,
1214
+ diffs: []
1215
+ });
1216
+ }
1217
+ entries = [...entries, ...extra];
1130
1218
  }
1131
- if (extra.length === 0) return folded;
1219
+ const existing = await this.retainExisting(agent, entries.map((entry) => entry.path));
1132
1220
  return {
1133
- entries: [...folded.entries, ...extra],
1221
+ entries: entries.filter((entry) => existing.has(entry.path)),
1134
1222
  asOfSeq: folded.asOfSeq,
1135
- truncated: folded.truncated || folded.entries.length + extra.length >= this.resolved.maxFiles
1223
+ truncated: folded.truncated || entries.length >= this.resolved.maxFiles
1136
1224
  };
1137
1225
  }
1138
1226
  /**
1139
1227
  * Every turn's file mutations for the turn-tail card — the single source of
1140
1228
  * truth the card renders (the client's old write/edit-only fold retired).
1141
- * Built by folding the session log per turn (write/edit calls, Code Mode
1229
+ * Built by folding the session log per turn (write/edit calls, PTC
1142
1230
  * dispatches borrowing the root call's turn, and render-intent paths from
1143
1231
  * result diff meta) and merging the collector's bash-written captures by
1144
1232
  * their own turn. The fold is cached per session and invalidated by the log
1145
1233
  * watermark, so repeated card fetches do not refold. A path touched in two
1146
- * turns appears in BOTH turn groups — per-turn attribution is exact.
1234
+ * turns appears in BOTH turn groups — per-turn attribution is exact. Only
1235
+ * paths that currently exist on disk are returned (a deleted temp script is
1236
+ * history, not a product); existence is probed fresh on every call, so the
1237
+ * cached fold is filtered per request rather than cached.
1147
1238
  * @param agent - owning live agent; its session log is the data source.
1148
- * @returns per-turn file groups plus the scanned watermark.
1239
+ * @returns existing per-turn file groups plus the scanned watermark.
1149
1240
  */
1150
- turnFiles(agent) {
1241
+ async turnFiles(agent) {
1151
1242
  const session = agent.session;
1152
- const events = session.events;
1243
+ const events = session.snapshotEvents();
1153
1244
  const asOfSeq = events.length === 0 ? -1 : events[events.length - 1].seq;
1154
1245
  const cached = this.turnCache.get(session.id);
1155
1246
  let byTurn;
@@ -1182,8 +1273,12 @@ let FilePreviewService = (() => {
1182
1273
  files: files.sort((a, b) => a.seq - b.seq)
1183
1274
  });
1184
1275
  }
1276
+ const existing = await this.retainExisting(agent, turns.flatMap((group) => group.files.map((file) => file.path)));
1185
1277
  return {
1186
- turns,
1278
+ turns: turns.map((group) => ({
1279
+ turn: group.turn,
1280
+ files: group.files.filter((file) => existing.has(file.path))
1281
+ })).filter((group) => group.files.length > 0),
1187
1282
  asOfSeq
1188
1283
  };
1189
1284
  }
@@ -1333,6 +1428,63 @@ let FilePreviewService = (() => {
1333
1428
  };
1334
1429
  }
1335
1430
  }
1431
+ /**
1432
+ * Open one recorded file in a specific host application (the "open in IDE"
1433
+ * gesture). The official open-in-app route only accepts directories — a
1434
+ * file path 404s — so file-exact opens go through this method: macOS
1435
+ * `open -a <App> <path>` via the same native-command runner reveal uses.
1436
+ * The browser names the application by official open-in-app catalog id
1437
+ * (probed from `/open-in-app/apps`); the id → app-name map lives in
1438
+ * open-external.ts. Non-macOS hosts and unknown ids answer without
1439
+ * launching, and the client hides the gesture.
1440
+ * @param agent - owning live agent; its session cwd anchors relative paths.
1441
+ * @param path - the display path recorded by the read/write/edit tool call.
1442
+ * @param app - official open-in-app catalog id (e.g. `cursor`).
1443
+ * @param signal - cooperative cancellation from the calling UI request.
1444
+ * @returns whether the application launched, or why not.
1445
+ */
1446
+ async openExternal(agent, path, app, signal) {
1447
+ if (typeof path !== "string" || path.length === 0 || typeof app !== "string" || app.length === 0) return {
1448
+ opened: false,
1449
+ reason: "missing"
1450
+ };
1451
+ if (macAppName(app) === void 0) return {
1452
+ opened: false,
1453
+ reason: "unknown-app"
1454
+ };
1455
+ const cwd = agent.session.header.cwd;
1456
+ let target;
1457
+ try {
1458
+ target = await this.fs.resolve(path, cwd === void 0 ? { signal } : {
1459
+ cwd,
1460
+ signal
1461
+ });
1462
+ } catch {
1463
+ return {
1464
+ opened: false,
1465
+ reason: "missing"
1466
+ };
1467
+ }
1468
+ let info;
1469
+ try {
1470
+ info = await this.fs.stat(target, signal);
1471
+ } catch {
1472
+ info = void 0;
1473
+ }
1474
+ if (info === void 0) return {
1475
+ opened: false,
1476
+ reason: "missing"
1477
+ };
1478
+ try {
1479
+ await this.openExternalNative(this.fs.processPath(target), app, signal);
1480
+ return { opened: true };
1481
+ } catch (error) {
1482
+ return {
1483
+ opened: false,
1484
+ reason: (error instanceof Error ? error.message : String(error)).startsWith("open-in-app is unsupported on ") ? "unsupported-platform" : "launch-failed"
1485
+ };
1486
+ }
1487
+ }
1336
1488
  };
1337
1489
  })();
1338
1490
  //#endregion