@danypops/pi-lector 0.12.9 → 0.12.10

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.
@@ -62,7 +62,7 @@ export class EditorState {
62
62
  // ── Normal mode ──────────────────────────────────────────────────────────
63
63
 
64
64
  private handleNormalKey(data: string): void {
65
- // Two-key sequences: dd, gg, yy.
65
+ // Two-key sequences: dd, gg, yy, ZZ, ZQ.
66
66
  if (this.normalPrefix) {
67
67
  const prefix = this.normalPrefix;
68
68
  this.normalPrefix = "";
@@ -78,6 +78,18 @@ export class EditorState {
78
78
  this.yankedLine = this.currentLineText;
79
79
  return;
80
80
  }
81
+ // Real vim's own close-on-demand mnemonics -- ZZ saves and quits (equivalent to :wq),
82
+ // ZQ quits without saving (equivalent to :q). Added so a host never needs to invent its
83
+ // own dismiss keybinding or reach into this class's internal mode/dirty state -- the
84
+ // editor decides its own exit paths, exactly like :q/:wq already do.
85
+ if (prefix === "Z" && data === "Z") {
86
+ this.pendingAction = { kind: "save-and-quit" };
87
+ return;
88
+ }
89
+ if (prefix === "Z" && data === "Q") {
90
+ this.pendingAction = { kind: "quit" };
91
+ return;
92
+ }
81
93
  // Prefix didn't complete into a known sequence -- fall through and handle `data` fresh.
82
94
  }
83
95
 
@@ -106,6 +118,7 @@ export class EditorState {
106
118
  case "d":
107
119
  case "g":
108
120
  case "y":
121
+ case "Z":
109
122
  this.normalPrefix = data;
110
123
  return;
111
124
  case "i":
@@ -15,8 +15,27 @@
15
15
  * construct and mount a real Lector editor Component directly, against its own tui/theme
16
16
  * implementation and its own ModalEditorHost backed by whatever it wants (a Lector daemon
17
17
  * client, a Vehicle operation, a plain filesystem call -- this package doesn't care).
18
+ *
19
+ * The same holds for ExplorerComponent (the oil.nvim-style directory explorer, built on the same
20
+ * EditorState engine): it only depends on `tui`/`theme` plus a DirectoryExplorerSession, its own
21
+ * real external contract. DirectoryExplorerSession happens to be declared alongside
22
+ * openDirectoryExplorer in directory-explorer-operations.ts, but the two are not the same kind of
23
+ * thing -- exactly the ModalEditorHost/openEditorFile split repeats here. openDirectoryExplorer
24
+ * (and openEditorFile) are this package's OWN Pi-extension-internal construction helpers, built on
25
+ * this package's own lectorClient()/withWorkspace -- deliberately not exported. A real external
26
+ * host is expected to supply its own DirectoryExplorerSession implementation, backed by whatever
27
+ * it wants, the same way Alignment's own ModalEditorHost implementation never reuses
28
+ * openEditorFile either.
18
29
  */
19
30
 
31
+ export type { DirectoryExplorerSession } from "./directory-explorer-operations.ts";
20
32
  export { type EditorAction, type EditorMode, EditorState } from "./editor-state.ts";
21
33
  export type { EditorTheme } from "./editor-theme.ts";
34
+ export { ExplorerComponent, type ExplorerResult, joinExplorerPath } from "./explorer-component.ts";
35
+ // runExplorerFlow is pure orchestration over the two interfaces above (browse, open a file into
36
+ // the real editor, return to the explorer at that file's own directory once it quits) -- exported
37
+ // for the same reason ExplorerComponent/DirectoryExplorerSession are: any real host can drive this
38
+ // exact, already-tested loop without re-deriving it, instead of only this package's own Pi
39
+ // extension entry point being able to.
40
+ export { type ExplorerFlowHost, runExplorerFlow } from "./explorer-flow.ts";
22
41
  export { ModalEditorComponent, type ModalEditorHost } from "./modal-editor-component.ts";
@@ -1,26 +1,40 @@
1
1
  import type { GithubRepoSearchResult, NpmPackageCandidate, SourcegraphCodeCandidate } from "@danypops/lector";
2
- import { lectorClient } from "../lector-client.ts";
2
+ import { invokeLectorVehicleOperation, type LectorVehicleCall } from "../vehicle-client.ts";
3
3
 
4
- /** Thin wrapper over search.githubRepos/search.npmPackages/search.sourcegraphCode -- explicit-query discovery inputs shaped for repo_cache/package_source, never open-ended discovery/trending. */
4
+ /** Matches EXTERNAL_SEARCH_PERMISSIONS' own declared value server-side (external-search/operation-registration.ts). */
5
+ const EXTERNAL_SEARCH_PERMISSIONS = ["external-search:read"];
6
+
7
+ /**
8
+ * Thin wrapper over search.githubRepos/search.npmPackages/search.sourcegraphCode, dispatched
9
+ * through invokeVehicleOperation -- explicit-query discovery inputs shaped for
10
+ * repo_cache/package_source, never open-ended discovery/trending.
11
+ */
5
12
  export interface ExternalSearchOperations {
6
- githubRepos(query: string, maxResults: number): Promise<GithubRepoSearchResult>;
7
- npmPackages(query: string, maxResults: number): Promise<{ candidates: readonly NpmPackageCandidate[] }>;
8
- sourcegraphCode(query: string, maxResults: number): Promise<{ candidates: readonly SourcegraphCodeCandidate[] }>;
13
+ githubRepos(query: string, maxResults: number, call: LectorVehicleCall): Promise<GithubRepoSearchResult>;
14
+ npmPackages(query: string, maxResults: number, call: LectorVehicleCall): Promise<{ candidates: readonly NpmPackageCandidate[] }>;
15
+ sourcegraphCode(query: string, maxResults: number, call: LectorVehicleCall): Promise<{ candidates: readonly SourcegraphCodeCandidate[] }>;
9
16
  }
10
17
 
11
18
  export function createExternalSearchOperations(): ExternalSearchOperations {
12
19
  return {
13
- async githubRepos(query, maxResults) {
14
- const client = await lectorClient();
15
- return client.call("search.githubRepos", { query, maxResults });
20
+ githubRepos(query, maxResults, call) {
21
+ return invokeLectorVehicleOperation<GithubRepoSearchResult>("search.githubRepos", { query, maxResults }, EXTERNAL_SEARCH_PERMISSIONS, call);
16
22
  },
17
- async npmPackages(query, maxResults) {
18
- const client = await lectorClient();
19
- return client.call("search.npmPackages", { query, maxResults });
23
+ npmPackages(query, maxResults, call) {
24
+ return invokeLectorVehicleOperation<{ candidates: readonly NpmPackageCandidate[] }>(
25
+ "search.npmPackages",
26
+ { query, maxResults },
27
+ EXTERNAL_SEARCH_PERMISSIONS,
28
+ call,
29
+ );
20
30
  },
21
- async sourcegraphCode(query, maxResults) {
22
- const client = await lectorClient();
23
- return client.call("search.sourcegraphCode", { query, maxResults });
31
+ sourcegraphCode(query, maxResults, call) {
32
+ return invokeLectorVehicleOperation<{ candidates: readonly SourcegraphCodeCandidate[] }>(
33
+ "search.sourcegraphCode",
34
+ { query, maxResults },
35
+ EXTERNAL_SEARCH_PERMISSIONS,
36
+ call,
37
+ );
24
38
  },
25
39
  };
26
40
  }
@@ -1,48 +1,57 @@
1
1
  import type { GitDiffResult, GitLogEntry, GitStatusSummary, OperationOutputs } from "@danypops/lector";
2
2
  import { lectorClient, withWorkspace, workspaceForDirectory } from "../lector-client.ts";
3
+ import { invokeLectorVehicleOperation, type LectorVehicleCall } from "../vehicle-client.ts";
3
4
 
4
5
  type SymbolComparison = OperationOutputs["workspace.compareSymbolAcrossVersions"];
5
6
 
7
+ /** Matches GIT_READ_PERMISSIONS' own declared value server-side (git/operation-registration.ts). */
8
+ const GIT_READ_PERMISSIONS = ["workspace:read"];
9
+
6
10
  /**
7
11
  * Thin wrappers over Lector's read-only git operations. `directory` is
8
12
  * required, same convention as find_symbols -- no implicit "whatever the
9
13
  * session's cwd is" fallback.
14
+ *
15
+ * status/log/diff dispatch through invokeLectorVehicleOperation (the real VehicleRegistry-backed
16
+ * workspace.gitStatus/gitLog/gitDiff operations -- see Lector Phase 1/2 of the vehicle-client-pi
17
+ * adoption epic) instead of a bare lectorClient().call(), gaining activity broadcasting, the
18
+ * local /safety ask gate, and idempotency-key/correlationId derivation for free. compareSymbol
19
+ * (workspace.compareSymbolAcrossVersions) has not migrated onto VehicleRegistry server-side yet,
20
+ * so it stays on the legacy dispatch unchanged.
10
21
  */
11
22
  export interface GitOperations {
12
- status(directory: string): Promise<GitStatusSummary>;
13
- log(directory: string, maxCount: number): Promise<readonly GitLogEntry[]>;
14
- diff(directory: string, ref: string | undefined, maxBytes: number): Promise<GitDiffResult>;
23
+ status(directory: string, call: LectorVehicleCall): Promise<GitStatusSummary>;
24
+ log(directory: string, maxCount: number, call: LectorVehicleCall): Promise<readonly GitLogEntry[]>;
25
+ diff(directory: string, ref: string | undefined, maxBytes: number, call: LectorVehicleCall): Promise<GitDiffResult>;
15
26
  compareSymbol(directory: string, path: string, symbolName: string, fromRef: string, toRef: string | undefined, maxBytes: number): Promise<SymbolComparison>;
16
27
  }
17
28
 
18
29
  export function createLectorGitOperations(): GitOperations {
19
30
  return {
20
- async status(directory) {
31
+ async status(directory, call) {
21
32
  return withWorkspace(
22
33
  () => workspaceForDirectory(directory),
23
- async ({ workspaceId }) => {
24
- const client = await lectorClient();
25
- return client.call("workspace.gitStatus", { workspaceId });
26
- },
34
+ ({ workspaceId }) => invokeLectorVehicleOperation<GitStatusSummary>("workspace.gitStatus", { workspaceId }, GIT_READ_PERMISSIONS, call),
27
35
  );
28
36
  },
29
- async log(directory, maxCount) {
37
+ async log(directory, maxCount, call) {
30
38
  return withWorkspace(
31
39
  () => workspaceForDirectory(directory),
32
40
  async ({ workspaceId }) => {
33
- const client = await lectorClient();
34
- const { entries } = await client.call("workspace.gitLog", { workspaceId, maxCount });
41
+ const { entries } = await invokeLectorVehicleOperation<{ entries: readonly GitLogEntry[] }>(
42
+ "workspace.gitLog",
43
+ { workspaceId, maxCount },
44
+ GIT_READ_PERMISSIONS,
45
+ call,
46
+ );
35
47
  return entries;
36
48
  },
37
49
  );
38
50
  },
39
- async diff(directory, ref, maxBytes) {
51
+ async diff(directory, ref, maxBytes, call) {
40
52
  return withWorkspace(
41
53
  () => workspaceForDirectory(directory),
42
- async ({ workspaceId }) => {
43
- const client = await lectorClient();
44
- return client.call("workspace.gitDiff", { workspaceId, ref, maxBytes });
45
- },
54
+ ({ workspaceId }) => invokeLectorVehicleOperation<GitDiffResult>("workspace.gitDiff", { workspaceId, ref, maxBytes }, GIT_READ_PERMISSIONS, call),
46
55
  );
47
56
  },
48
57
  async compareSymbol(directory, path, symbolName, fromRef, toRef, maxBytes) {
@@ -128,6 +128,7 @@ import { createLectorSearchOperations } from "./search/operations.ts";
128
128
  import { formatSearchCall, formatSearchResult } from "./search/rendering.ts";
129
129
  import { type AnnotationAnchorInput, createLectorSymbolAnnotationOperations } from "./symbol-annotation/operations.ts";
130
130
  import { formatAnnotationDetail, formatAnnotationListSummary, formatAnnotationSummary } from "./symbol-annotation/rendering.ts";
131
+ import type { LectorVehicleCall } from "./vehicle-client.ts";
131
132
  import {
132
133
  type CachePresentationState,
133
134
  cacheContextMessage,
@@ -906,30 +907,42 @@ export default function (pi: ExtensionAPI) {
906
907
  rootId: Type.Optional(Type.String({ description: "The subtree's root annotation id -- required for tree" })),
907
908
  maxDepth: Type.Optional(Type.Number({ description: "Maximum containment hops from rootId to include -- required for tree" })),
908
909
  }),
909
- async execute(_toolCallId, params) {
910
+ async execute(toolCallId, params, signal, _onUpdate, ctx): Promise<AgentToolResult<SymbolAnnotationToolDetails>> {
910
911
  const path = resolve(cwd, params.path);
912
+ const vehicleCall: LectorVehicleCall = {
913
+ toolName: "symbol_annotations",
914
+ toolCallId,
915
+ signal,
916
+ context: ctx,
917
+ };
911
918
  const details: SymbolAnnotationToolDetails = {};
912
919
  let text: string;
913
920
  if (params.action === "create") {
914
921
  if (!params.subtype || !params.title || params.body === undefined || !params.anchors || params.anchors.length === 0) {
915
922
  throw new Error("symbol_annotations create requires subtype, title, body, and at least one anchor");
916
923
  }
917
- const { annotation } = await symbolAnnotationOperations.create(path, params.subtype, params.title, params.body, resolveAnchorInputs(params.anchors));
924
+ const { annotation } = await symbolAnnotationOperations.create(
925
+ path,
926
+ params.subtype,
927
+ params.title,
928
+ params.body,
929
+ resolveAnchorInputs(params.anchors),
930
+ vehicleCall,
931
+ );
918
932
  details.annotation = annotation;
919
933
  text = formatAnnotationDetail(annotation);
920
934
  } else if (params.action === "get") {
921
935
  if (!params.id) throw new Error("symbol_annotations get requires id");
922
- const { annotation } = await symbolAnnotationOperations.get(path, params.id);
936
+ const { annotation } = await symbolAnnotationOperations.get(path, params.id, vehicleCall);
923
937
  details.annotation = annotation;
924
938
  text = annotation ? formatAnnotationDetail(annotation) : `no annotation "${params.id}"`;
925
939
  } else if (params.action === "list") {
926
940
  const status = params.listStatus === "fresh" || params.listStatus === "stale" || params.listStatus === "scrubbed" ? params.listStatus : undefined;
927
- const { annotations } = await symbolAnnotationOperations.list(path, {
928
- subtype: params.listSubtype,
929
- status,
930
- maxResults: params.maxResults,
931
- query: params.listQuery,
932
- });
941
+ const { annotations } = await symbolAnnotationOperations.list(
942
+ path,
943
+ { subtype: params.listSubtype, status, maxResults: params.maxResults, query: params.listQuery },
944
+ vehicleCall,
945
+ );
933
946
  details.annotations = annotations;
934
947
  text = annotations.length === 0 ? "no annotations" : annotations.map(formatAnnotationDetail).join("\n\n");
935
948
  } else if (params.action === "refresh") {
@@ -943,32 +956,33 @@ export default function (pi: ExtensionAPI) {
943
956
  params.title,
944
957
  params.body,
945
958
  resolveAnchorInputs(params.anchors),
959
+ vehicleCall,
946
960
  );
947
961
  details.annotation = annotation;
948
962
  text = annotation ? formatAnnotationDetail(annotation) : `no annotation "${params.id}"`;
949
963
  } else if (params.action === "scrub") {
950
964
  if (!params.id) throw new Error("symbol_annotations scrub requires id");
951
- const { scrubbed } = await symbolAnnotationOperations.scrub(path, params.id);
965
+ const { scrubbed } = await symbolAnnotationOperations.scrub(path, params.id, vehicleCall);
952
966
  details.scrubbed = scrubbed;
953
967
  text = scrubbed ? `scrubbed ${params.id}` : `"${params.id}" was already scrubbed or does not exist`;
954
968
  } else if (params.action === "restore") {
955
969
  if (!params.id) throw new Error("symbol_annotations restore requires id");
956
- const { restored } = await symbolAnnotationOperations.restore(path, params.id);
970
+ const { restored } = await symbolAnnotationOperations.restore(path, params.id, vehicleCall);
957
971
  details.restored = restored;
958
972
  text = restored ? `restored ${params.id}` : `"${params.id}" was not scrubbed or does not exist`;
959
973
  } else if (params.action === "contain") {
960
974
  if (!params.parentId || !params.childId) throw new Error("symbol_annotations contain requires parentId and childId");
961
- const { contained } = await symbolAnnotationOperations.contain(path, params.parentId, params.childId);
975
+ const { contained } = await symbolAnnotationOperations.contain(path, params.parentId, params.childId, vehicleCall);
962
976
  details.contained = contained;
963
977
  text = `"${params.parentId}" now contains "${params.childId}"`;
964
978
  } else if (params.action === "uncontain") {
965
979
  if (!params.parentId || !params.childId) throw new Error("symbol_annotations uncontain requires parentId and childId");
966
- const { uncontained } = await symbolAnnotationOperations.uncontain(path, params.parentId, params.childId);
980
+ const { uncontained } = await symbolAnnotationOperations.uncontain(path, params.parentId, params.childId, vehicleCall);
967
981
  details.uncontained = uncontained;
968
982
  text = uncontained ? `"${params.parentId}" no longer contains "${params.childId}"` : `"${params.parentId}" did not contain "${params.childId}"`;
969
983
  } else if (params.action === "tree") {
970
984
  if (!params.rootId || params.maxDepth === undefined) throw new Error("symbol_annotations tree requires rootId and maxDepth");
971
- const { annotations } = await symbolAnnotationOperations.tree(path, params.rootId, params.maxDepth);
985
+ const { annotations } = await symbolAnnotationOperations.tree(path, params.rootId, params.maxDepth, vehicleCall);
972
986
  details.annotations = annotations;
973
987
  text = annotations.length === 0 ? `no annotation "${params.rootId}"` : annotations.map(formatAnnotationDetail).join("\n\n");
974
988
  } else {
@@ -1253,16 +1267,17 @@ export default function (pi: ExtensionAPI) {
1253
1267
  Type.String({ description: "Git ref for the 'after' version; omit to compare against the current working tree -- action=compare-symbol only" }),
1254
1268
  ),
1255
1269
  }),
1256
- async execute(_toolCallId, params) {
1270
+ async execute(toolCallId, params, signal, _onUpdate, ctx): Promise<AgentToolResult<GitToolDetails>> {
1257
1271
  const directory = resolve(cwd, params.directory);
1272
+ const vehicleCall: LectorVehicleCall = { toolName: "git", toolCallId, signal, context: ctx };
1258
1273
  if (params.action === "status") {
1259
- const summary = await gitOperations.status(directory);
1274
+ const summary = await gitOperations.status(directory, vehicleCall);
1260
1275
  const details: GitToolDetails = { action: "status", summary };
1261
1276
  return { content: [{ type: "text", text: JSON.stringify(summary) }], details };
1262
1277
  }
1263
1278
  if (params.action === "log") {
1264
1279
  if (params.maxCount === undefined) throw new Error("git action=log requires maxCount");
1265
- const entries = await gitOperations.log(directory, params.maxCount);
1280
+ const entries = await gitOperations.log(directory, params.maxCount, vehicleCall);
1266
1281
  const text =
1267
1282
  entries.length === 0 ? "No commits found." : entries.map((e) => `${e.sha.slice(0, 8)} ${e.authoredAt} ${e.authorName} -- ${e.message}`).join("\n");
1268
1283
  const details: GitToolDetails = { action: "log", entries };
@@ -1270,7 +1285,7 @@ export default function (pi: ExtensionAPI) {
1270
1285
  }
1271
1286
  if (params.action === "diff") {
1272
1287
  if (params.maxBytes === undefined) throw new Error("git action=diff requires maxBytes");
1273
- const result = await gitOperations.diff(directory, params.ref, params.maxBytes);
1288
+ const result = await gitOperations.diff(directory, params.ref, params.maxBytes, vehicleCall);
1274
1289
  const details: GitToolDetails = { action: "diff", result };
1275
1290
  return { content: [{ type: "text", text: result.diff.length === 0 ? "No differences." : result.diff }], details };
1276
1291
  }
@@ -1529,11 +1544,17 @@ export default function (pi: ExtensionAPI) {
1529
1544
  maxResults: Type.Optional(Type.Number({ description: "Required for action=list -- maximum entries to return, newest first" })),
1530
1545
  entryId: Type.Optional(Type.String({ description: "Required for action=revert -- an id returned by a prior action=list" })),
1531
1546
  }),
1532
- async execute(_toolCallId, params): Promise<AgentToolResult<MutationHistoryToolDetails>> {
1547
+ async execute(toolCallId, params, signal, _onUpdate, ctx): Promise<AgentToolResult<MutationHistoryToolDetails>> {
1533
1548
  const absolutePath = resolve(cwd, params.path);
1549
+ const vehicleCall: LectorVehicleCall = {
1550
+ toolName: "mutation_history",
1551
+ toolCallId,
1552
+ signal,
1553
+ context: ctx,
1554
+ };
1534
1555
  if (params.action === "list") {
1535
1556
  if (params.maxResults === undefined) throw new Error("mutation_history action=list requires maxResults");
1536
- const entries = await mutationHistoryOperations.list(absolutePath, params.maxResults);
1557
+ const entries = await mutationHistoryOperations.list(absolutePath, params.maxResults, vehicleCall);
1537
1558
  const text =
1538
1559
  entries.length === 0
1539
1560
  ? "no recorded mutation history for this path"
@@ -1541,7 +1562,7 @@ export default function (pi: ExtensionAPI) {
1541
1562
  return { content: [{ type: "text", text }], details: { action: "list", entries } };
1542
1563
  }
1543
1564
  if (params.entryId === undefined) throw new Error("mutation_history action=revert requires entryId");
1544
- const reverted = await mutationHistoryOperations.revert(absolutePath, params.entryId);
1565
+ const reverted = await mutationHistoryOperations.revert(absolutePath, params.entryId, vehicleCall);
1545
1566
  return {
1546
1567
  content: [{ type: "text", text: `${reverted.path} reverted -> ${reverted.newHash ?? "(deleted)"}` }],
1547
1568
  details: { action: "revert", reverted },
@@ -1711,15 +1732,28 @@ export default function (pi: ExtensionAPI) {
1711
1732
  maxResults: Type.Optional(Type.Number({ description: "Required for action=list -- maximum entries to return in this page" })),
1712
1733
  cursor: Type.Optional(Type.String({ description: "action=list only -- opaque cursor from a prior call's nextCursor, to fetch the next page" })),
1713
1734
  }),
1714
- async execute(_toolCallId, params): Promise<AgentToolResult<RepoCacheToolDetails>> {
1735
+ async execute(toolCallId, params, signal, _onUpdate, ctx): Promise<AgentToolResult<RepoCacheToolDetails>> {
1736
+ const vehicleCall: LectorVehicleCall = {
1737
+ toolName: "repo_cache",
1738
+ toolCallId,
1739
+ signal,
1740
+ context: ctx,
1741
+ };
1715
1742
  if (params.action === "fetch") {
1716
1743
  if (!params.owner || !params.repo) throw new Error("repo_cache action=fetch requires owner and repo");
1717
- const result = await repoFetchOperations.fetch(params.host ?? "github.com", params.owner, params.repo, params.ref ?? null, params.forceRefresh);
1744
+ const result = await repoFetchOperations.fetch(
1745
+ params.host ?? "github.com",
1746
+ params.owner,
1747
+ params.repo,
1748
+ params.ref ?? null,
1749
+ params.forceRefresh,
1750
+ vehicleCall,
1751
+ );
1718
1752
  return { content: [{ type: "text", text: JSON.stringify(result) }], details: { action: "fetch", result } };
1719
1753
  }
1720
1754
  if (params.action === "evict") {
1721
1755
  if (!params.owner || !params.repo) throw new Error("repo_cache action=evict requires owner and repo");
1722
- const result = await repoCacheEvictOperations.evict(params.host ?? "github.com", params.owner, params.repo, params.ref ?? null);
1756
+ const result = await repoCacheEvictOperations.evict(params.host ?? "github.com", params.owner, params.repo, params.ref ?? null, vehicleCall);
1723
1757
  return { content: [{ type: "text", text: JSON.stringify(result) }], details: { action: "evict", result } };
1724
1758
  }
1725
1759
  if (params.maxResults === undefined) throw new Error("repo_cache action=list requires maxResults");
@@ -1727,6 +1761,7 @@ export default function (pi: ExtensionAPI) {
1727
1761
  { text: params.text, host: params.host, owner: params.owner, repo: params.repo, ref: params.ref },
1728
1762
  params.maxResults,
1729
1763
  params.cursor,
1764
+ vehicleCall,
1730
1765
  );
1731
1766
  return { content: [{ type: "text", text: JSON.stringify(page) }], details: { action: "list", page } };
1732
1767
  },
@@ -1791,17 +1826,23 @@ export default function (pi: ExtensionAPI) {
1791
1826
  }),
1792
1827
  maxResults: Type.Optional(Type.Number({ description: "Maximum candidates to return (default 20)" })),
1793
1828
  }),
1794
- async execute(_toolCallId, params): Promise<AgentToolResult<ExternalSearchToolDetails>> {
1829
+ async execute(toolCallId, params, signal, _onUpdate, ctx): Promise<AgentToolResult<ExternalSearchToolDetails>> {
1795
1830
  const maxResults = params.maxResults ?? DEFAULT_EXTERNAL_SEARCH_MAX_RESULTS;
1831
+ const vehicleCall: LectorVehicleCall = {
1832
+ toolName: "external_search",
1833
+ toolCallId,
1834
+ signal,
1835
+ context: ctx,
1836
+ };
1796
1837
  if (params.action === "github_repos") {
1797
- const result = await externalSearchOperations.githubRepos(params.query, maxResults);
1838
+ const result = await externalSearchOperations.githubRepos(params.query, maxResults, vehicleCall);
1798
1839
  return { content: [{ type: "text", text: JSON.stringify(result) }], details: { action: "github_repos", result } };
1799
1840
  }
1800
1841
  if (params.action === "npm_packages") {
1801
- const result = await externalSearchOperations.npmPackages(params.query, maxResults);
1842
+ const result = await externalSearchOperations.npmPackages(params.query, maxResults, vehicleCall);
1802
1843
  return { content: [{ type: "text", text: JSON.stringify(result) }], details: { action: "npm_packages", result } };
1803
1844
  }
1804
- const result = await externalSearchOperations.sourcegraphCode(params.query, maxResults);
1845
+ const result = await externalSearchOperations.sourcegraphCode(params.query, maxResults, vehicleCall);
1805
1846
  return { content: [{ type: "text", text: JSON.stringify(result) }], details: { action: "sourcegraph_code", result } };
1806
1847
  },
1807
1848
  renderCall(args, theme, context) {
@@ -1,33 +1,49 @@
1
1
  import type { MutationHistoryEntry } from "@danypops/lector";
2
- import { lectorClient, withWorkspace, workspaceForPath } from "../lector-client.ts";
2
+ import { withWorkspace, workspaceForPath } from "../lector-client.ts";
3
+ import { invokeLectorVehicleOperation, type LectorVehicleCall } from "../vehicle-client.ts";
3
4
  import { toWorkspaceRelativePath } from "../workspace-relative-path.ts";
4
5
 
5
- /** Thin wrapper over Lector's mutation history: every successful edit is recorded, and any entry can be reverted -- guarded the same way every other Lector write is. */
6
+ /** Match MUTATION_HISTORY_READ_PERMISSIONS/MUTATION_HISTORY_WRITE_PERMISSIONS' own declared values server-side (mutation-history/operation-registration.ts). */
7
+ const MUTATION_HISTORY_READ_PERMISSIONS = ["workspace:read"];
8
+ const MUTATION_HISTORY_WRITE_PERMISSIONS = ["workspace:write"];
9
+
10
+ /**
11
+ * Thin wrapper over Lector's mutation history, dispatched through invokeLectorVehicleOperation:
12
+ * every successful edit is recorded, and any entry can be reverted -- guarded the same way every
13
+ * other Lector write is.
14
+ */
6
15
  export interface MutationHistoryOperations {
7
- list(absolutePath: string, maxResults: number): Promise<readonly MutationHistoryEntry[]>;
8
- revert(absolutePath: string, entryId: string): Promise<{ path: string; newHash: string | null }>;
16
+ list(absolutePath: string, maxResults: number, call: LectorVehicleCall): Promise<readonly MutationHistoryEntry[]>;
17
+ revert(absolutePath: string, entryId: string, call: LectorVehicleCall): Promise<{ path: string; newHash: string | null }>;
9
18
  }
10
19
 
11
20
  export function createMutationHistoryOperations(): MutationHistoryOperations {
12
21
  return {
13
- list(absolutePath, maxResults) {
22
+ list(absolutePath, maxResults, call) {
14
23
  return withWorkspace(
15
24
  () => workspaceForPath(absolutePath),
16
25
  async ({ workspaceId, root }) => {
17
- const client = await lectorClient();
18
26
  const path = toWorkspaceRelativePath(root, absolutePath);
19
- const { entries } = await client.call("workspace.mutationHistory", { workspaceId, path, maxResults });
27
+ const { entries } = await invokeLectorVehicleOperation<{ entries: readonly MutationHistoryEntry[] }>(
28
+ "workspace.mutationHistory",
29
+ { workspaceId, path, maxResults },
30
+ MUTATION_HISTORY_READ_PERMISSIONS,
31
+ call,
32
+ );
20
33
  return entries;
21
34
  },
22
35
  );
23
36
  },
24
- revert(absolutePath, entryId) {
37
+ revert(absolutePath, entryId, call) {
25
38
  return withWorkspace(
26
39
  () => workspaceForPath(absolutePath),
27
- async ({ workspaceId }) => {
28
- const client = await lectorClient();
29
- return client.callOnce("workspace.revertMutation", { workspaceId, entryId });
30
- },
40
+ ({ workspaceId }) =>
41
+ invokeLectorVehicleOperation<{ path: string; newHash: string | null }>(
42
+ "workspace.revertMutation",
43
+ { workspaceId, entryId },
44
+ MUTATION_HISTORY_WRITE_PERMISSIONS,
45
+ call,
46
+ ),
31
47
  );
32
48
  },
33
49
  };
@@ -1,19 +1,22 @@
1
- import { lectorClient } from "../lector-client.ts";
1
+ import { invokeLectorVehicleOperation, type LectorVehicleCall } from "../vehicle-client.ts";
2
+
3
+ /** Matches REPO_WRITE_PERMISSIONS' own declared value server-side (repo-fetcher/operation-registration.ts). */
4
+ const REPO_WRITE_PERMISSIONS = ["workspace:write"];
2
5
 
3
6
  /**
4
- * Thin wrapper over repo.evictCache -- no `directory`/workspaceForDirectory resolution (matching
5
- * repo-fetch/operations.ts and repo-cache/list-operations.ts: this targets the daemon-wide fetch
6
- * cache, not a workspace-scoped concept).
7
+ * Thin wrapper over repo.evictCache, dispatched through invokeVehicleOperation -- no
8
+ * `directory`/workspaceForDirectory resolution (matching repo-fetch/operations.ts and
9
+ * repo-cache/list-operations.ts: this targets the daemon-wide fetch cache, not a
10
+ * workspace-scoped concept).
7
11
  */
8
12
  export interface RepoCacheEvictOperations {
9
- evict(host: string, owner: string, repo: string, ref: string | null): Promise<{ evicted: boolean }>;
13
+ evict(host: string, owner: string, repo: string, ref: string | null, call: LectorVehicleCall): Promise<{ evicted: boolean }>;
10
14
  }
11
15
 
12
16
  export function createRepoCacheEvictOperations(): RepoCacheEvictOperations {
13
17
  return {
14
- async evict(host, owner, repo, ref) {
15
- const client = await lectorClient();
16
- return client.callOnce("repo.evictCache", { host, owner, repo, ref });
18
+ evict(host, owner, repo, ref, call) {
19
+ return invokeLectorVehicleOperation<{ evicted: boolean }>("repo.evictCache", { host, owner, repo, ref }, REPO_WRITE_PERMISSIONS, call);
17
20
  },
18
21
  };
19
22
  }
@@ -1,24 +1,28 @@
1
1
  import type { CachedRepositoryPage } from "@danypops/lector";
2
- import { lectorClient } from "../lector-client.ts";
2
+ import { invokeLectorVehicleOperation, type LectorVehicleCall } from "../vehicle-client.ts";
3
+
4
+ /** Matches REPO_LIST_CACHE_PERMISSIONS' own declared value server-side (repo-fetcher/operation-registration.ts). */
5
+ const REPO_LIST_CACHE_PERMISSIONS = ["workspace:read"];
3
6
 
4
7
  /**
5
- * Thin wrapper over repo.listCache -- no network, no cache mutation, no `directory`/
6
- * workspaceForDirectory resolution (matching repo-fetch/operations.ts: this queries the
7
- * daemon-wide fetch cache, not a workspace-scoped concept).
8
+ * Thin wrapper over repo.listCache, dispatched through invokeVehicleOperation -- no network, no
9
+ * cache mutation, no `directory`/workspaceForDirectory resolution (matching
10
+ * repo-fetch/operations.ts: this queries the daemon-wide fetch cache, not a workspace-scoped
11
+ * concept).
8
12
  */
9
13
  export interface RepoCacheListOperations {
10
14
  list(
11
15
  filters: { text?: string; host?: string; owner?: string; repo?: string; ref?: string },
12
16
  maxResults: number,
13
- cursor?: string,
17
+ cursor: string | undefined,
18
+ call: LectorVehicleCall,
14
19
  ): Promise<CachedRepositoryPage>;
15
20
  }
16
21
 
17
22
  export function createRepoCacheListOperations(): RepoCacheListOperations {
18
23
  return {
19
- async list(filters, maxResults, cursor) {
20
- const client = await lectorClient();
21
- return client.call("repo.listCache", { ...filters, maxResults, cursor });
24
+ list(filters, maxResults, cursor, call) {
25
+ return invokeLectorVehicleOperation<CachedRepositoryPage>("repo.listCache", { ...filters, maxResults, cursor }, REPO_LIST_CACHE_PERMISSIONS, call);
22
26
  },
23
27
  };
24
28
  }
@@ -1,20 +1,36 @@
1
1
  import type { RepoFetchResult } from "@danypops/lector";
2
- import { lectorClient } from "../lector-client.ts";
2
+ import { invokeLectorVehicleOperation, type LectorVehicleCall } from "../vehicle-client.ts";
3
+
4
+ /** Matches REPO_WRITE_PERMISSIONS' own declared value server-side (repo-fetcher/operation-registration.ts). */
5
+ const REPO_WRITE_PERMISSIONS = ["workspace:write"];
3
6
 
4
7
  /**
5
- * Thin wrapper over repo.fetch. No `directory`/workspaceForDirectory resolution here -- unlike
8
+ * Thin wrapper over repo.fetch, dispatched through invokeVehicleOperation (the real
9
+ * VehicleRegistry-backed operation -- see Lector's vehicle-client-pi adoption epic) instead of a
10
+ * bare lectorClient().call(). No `directory`/workspaceForDirectory resolution here -- unlike
6
11
  * every other tool in this extension, this one doesn't target an existing local directory, it
7
12
  * creates a new registered workspace from a fetched external repo.
8
13
  */
9
14
  export interface RepoFetchOperations {
10
- fetch(host: string, owner: string, repo: string, ref: string | null, forceRefresh?: boolean): Promise<RepoFetchResult & { workspaceId: string }>;
15
+ fetch(
16
+ host: string,
17
+ owner: string,
18
+ repo: string,
19
+ ref: string | null,
20
+ forceRefresh: boolean | undefined,
21
+ call: LectorVehicleCall,
22
+ ): Promise<RepoFetchResult & { workspaceId: string }>;
11
23
  }
12
24
 
13
25
  export function createLectorRepoFetchOperations(): RepoFetchOperations {
14
26
  return {
15
- async fetch(host, owner, repo, ref, forceRefresh) {
16
- const client = await lectorClient();
17
- return client.callOnce("repo.fetch", { host, owner, repo, ref, forceRefresh });
27
+ fetch(host, owner, repo, ref, forceRefresh, call) {
28
+ return invokeLectorVehicleOperation<RepoFetchResult & { workspaceId: string }>(
29
+ "repo.fetch",
30
+ { host, owner, repo, ref, forceRefresh },
31
+ REPO_WRITE_PERMISSIONS,
32
+ call,
33
+ );
18
34
  },
19
35
  };
20
36
  }
@@ -1,5 +1,10 @@
1
1
  import type { OperationInputs, OperationOutputs } from "@danypops/lector";
2
- import { lectorClient, withWorkspace, workspaceForAnnotationPath } from "../lector-client.ts";
2
+ import { withWorkspace, workspaceForAnnotationPath } from "../lector-client.ts";
3
+ import { invokeLectorVehicleOperation, type LectorVehicleCall } from "../vehicle-client.ts";
4
+
5
+ /** Match ANNOTATION_READ_PERMISSIONS/ANNOTATION_WRITE_PERMISSIONS' own declared values server-side (symbol-annotation/operation-registration.ts). */
6
+ const ANNOTATION_READ_PERMISSIONS = ["workspace:read"];
7
+ const ANNOTATION_WRITE_PERMISSIONS = ["workspace:write"];
3
8
 
4
9
  /** A bare symbol position an anchor is given as -- symbolNodeId and the anchor's baseline file hash are derived server-side, never supplied by the caller. */
5
10
  export interface AnnotationAnchorInput {
@@ -23,11 +28,13 @@ export interface SymbolAnnotationOperations {
23
28
  title: string,
24
29
  body: string,
25
30
  anchors: readonly AnnotationAnchorInput[],
31
+ call: LectorVehicleCall,
26
32
  ): Promise<OperationOutputs["workspace.createAnnotation"]>;
27
- get(path: string, id: string): Promise<OperationOutputs["workspace.getAnnotation"]>;
33
+ get(path: string, id: string, call: LectorVehicleCall): Promise<OperationOutputs["workspace.getAnnotation"]>;
28
34
  list(
29
35
  path: string,
30
- options?: { subtype?: string; status?: OperationInputs["workspace.listAnnotations"]["status"]; maxResults?: number; query?: string },
36
+ options: { subtype?: string; status?: OperationInputs["workspace.listAnnotations"]["status"]; maxResults?: number; query?: string },
37
+ call: LectorVehicleCall,
31
38
  ): Promise<OperationOutputs["workspace.listAnnotations"]>;
32
39
  refresh(
33
40
  path: string,
@@ -36,101 +43,123 @@ export interface SymbolAnnotationOperations {
36
43
  title: string,
37
44
  body: string,
38
45
  anchors: readonly AnnotationAnchorInput[],
46
+ call: LectorVehicleCall,
39
47
  ): Promise<OperationOutputs["workspace.refreshAnnotation"]>;
40
- scrub(path: string, id: string): Promise<OperationOutputs["workspace.scrubAnnotation"]>;
41
- restore(path: string, id: string): Promise<OperationOutputs["workspace.restoreAnnotation"]>;
42
- contain(path: string, parentId: string, childId: string): Promise<OperationOutputs["workspace.containAnnotation"]>;
43
- uncontain(path: string, parentId: string, childId: string): Promise<OperationOutputs["workspace.uncontainAnnotation"]>;
44
- tree(path: string, rootId: string, maxDepth: number): Promise<OperationOutputs["workspace.annotationTree"]>;
48
+ scrub(path: string, id: string, call: LectorVehicleCall): Promise<OperationOutputs["workspace.scrubAnnotation"]>;
49
+ restore(path: string, id: string, call: LectorVehicleCall): Promise<OperationOutputs["workspace.restoreAnnotation"]>;
50
+ contain(path: string, parentId: string, childId: string, call: LectorVehicleCall): Promise<OperationOutputs["workspace.containAnnotation"]>;
51
+ uncontain(path: string, parentId: string, childId: string, call: LectorVehicleCall): Promise<OperationOutputs["workspace.uncontainAnnotation"]>;
52
+ tree(path: string, rootId: string, maxDepth: number, call: LectorVehicleCall): Promise<OperationOutputs["workspace.annotationTree"]>;
45
53
  }
46
54
 
47
55
  export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperations {
48
56
  return {
49
- async create(path, subtype, title, body, anchors) {
57
+ async create(path, subtype, title, body, anchors, call) {
50
58
  return withWorkspace(
51
59
  () => workspaceForAnnotationPath(path),
52
- async ({ workspaceId }) => {
53
- const client = await lectorClient();
54
- return client.callOnce("workspace.createAnnotation", { workspaceId, subtype, title, body, anchors });
55
- },
60
+ ({ workspaceId }) =>
61
+ invokeLectorVehicleOperation<OperationOutputs["workspace.createAnnotation"]>(
62
+ "workspace.createAnnotation",
63
+ { workspaceId, subtype, title, body, anchors },
64
+ ANNOTATION_WRITE_PERMISSIONS,
65
+ call,
66
+ ),
56
67
  );
57
68
  },
58
- async get(path, id) {
69
+ async get(path, id, call) {
59
70
  return withWorkspace(
60
71
  () => workspaceForAnnotationPath(path),
61
- async ({ workspaceId }) => {
62
- const client = await lectorClient();
63
- return client.call("workspace.getAnnotation", { workspaceId, id });
64
- },
72
+ ({ workspaceId }) =>
73
+ invokeLectorVehicleOperation<OperationOutputs["workspace.getAnnotation"]>(
74
+ "workspace.getAnnotation",
75
+ { workspaceId, id },
76
+ ANNOTATION_READ_PERMISSIONS,
77
+ call,
78
+ ),
65
79
  );
66
80
  },
67
- async list(path, options = {}) {
81
+ async list(path, options, call) {
68
82
  return withWorkspace(
69
83
  () => workspaceForAnnotationPath(path),
70
- async ({ workspaceId }) => {
71
- const client = await lectorClient();
72
- return client.call("workspace.listAnnotations", {
73
- workspaceId,
74
- subtype: options.subtype,
75
- status: options.status,
76
- maxResults: options.maxResults,
77
- query: options.query,
78
- });
79
- },
84
+ ({ workspaceId }) =>
85
+ invokeLectorVehicleOperation<OperationOutputs["workspace.listAnnotations"]>(
86
+ "workspace.listAnnotations",
87
+ { workspaceId, subtype: options.subtype, status: options.status, maxResults: options.maxResults, query: options.query },
88
+ ANNOTATION_READ_PERMISSIONS,
89
+ call,
90
+ ),
80
91
  );
81
92
  },
82
- async refresh(path, id, subtype, title, body, anchors) {
93
+ async refresh(path, id, subtype, title, body, anchors, call) {
83
94
  return withWorkspace(
84
95
  () => workspaceForAnnotationPath(path),
85
- async ({ workspaceId }) => {
86
- const client = await lectorClient();
87
- return client.callOnce("workspace.refreshAnnotation", { workspaceId, id, subtype, title, body, anchors });
88
- },
96
+ ({ workspaceId }) =>
97
+ invokeLectorVehicleOperation<OperationOutputs["workspace.refreshAnnotation"]>(
98
+ "workspace.refreshAnnotation",
99
+ { workspaceId, id, subtype, title, body, anchors },
100
+ ANNOTATION_WRITE_PERMISSIONS,
101
+ call,
102
+ ),
89
103
  );
90
104
  },
91
- async scrub(path, id) {
105
+ async scrub(path, id, call) {
92
106
  return withWorkspace(
93
107
  () => workspaceForAnnotationPath(path),
94
- async ({ workspaceId }) => {
95
- const client = await lectorClient();
96
- return client.callOnce("workspace.scrubAnnotation", { workspaceId, id });
97
- },
108
+ ({ workspaceId }) =>
109
+ invokeLectorVehicleOperation<OperationOutputs["workspace.scrubAnnotation"]>(
110
+ "workspace.scrubAnnotation",
111
+ { workspaceId, id },
112
+ ANNOTATION_WRITE_PERMISSIONS,
113
+ call,
114
+ ),
98
115
  );
99
116
  },
100
- async restore(path, id) {
117
+ async restore(path, id, call) {
101
118
  return withWorkspace(
102
119
  () => workspaceForAnnotationPath(path),
103
- async ({ workspaceId }) => {
104
- const client = await lectorClient();
105
- return client.callOnce("workspace.restoreAnnotation", { workspaceId, id });
106
- },
120
+ ({ workspaceId }) =>
121
+ invokeLectorVehicleOperation<OperationOutputs["workspace.restoreAnnotation"]>(
122
+ "workspace.restoreAnnotation",
123
+ { workspaceId, id },
124
+ ANNOTATION_WRITE_PERMISSIONS,
125
+ call,
126
+ ),
107
127
  );
108
128
  },
109
- async contain(path, parentId, childId) {
129
+ async contain(path, parentId, childId, call) {
110
130
  return withWorkspace(
111
131
  () => workspaceForAnnotationPath(path),
112
- async ({ workspaceId }) => {
113
- const client = await lectorClient();
114
- return client.callOnce("workspace.containAnnotation", { workspaceId, parentId, childId });
115
- },
132
+ ({ workspaceId }) =>
133
+ invokeLectorVehicleOperation<OperationOutputs["workspace.containAnnotation"]>(
134
+ "workspace.containAnnotation",
135
+ { workspaceId, parentId, childId },
136
+ ANNOTATION_WRITE_PERMISSIONS,
137
+ call,
138
+ ),
116
139
  );
117
140
  },
118
- async uncontain(path, parentId, childId) {
141
+ async uncontain(path, parentId, childId, call) {
119
142
  return withWorkspace(
120
143
  () => workspaceForAnnotationPath(path),
121
- async ({ workspaceId }) => {
122
- const client = await lectorClient();
123
- return client.callOnce("workspace.uncontainAnnotation", { workspaceId, parentId, childId });
124
- },
144
+ ({ workspaceId }) =>
145
+ invokeLectorVehicleOperation<OperationOutputs["workspace.uncontainAnnotation"]>(
146
+ "workspace.uncontainAnnotation",
147
+ { workspaceId, parentId, childId },
148
+ ANNOTATION_WRITE_PERMISSIONS,
149
+ call,
150
+ ),
125
151
  );
126
152
  },
127
- async tree(path, rootId, maxDepth) {
153
+ async tree(path, rootId, maxDepth, call) {
128
154
  return withWorkspace(
129
155
  () => workspaceForAnnotationPath(path),
130
- async ({ workspaceId }) => {
131
- const client = await lectorClient();
132
- return client.call("workspace.annotationTree", { workspaceId, rootId, maxDepth });
133
- },
156
+ ({ workspaceId }) =>
157
+ invokeLectorVehicleOperation<OperationOutputs["workspace.annotationTree"]>(
158
+ "workspace.annotationTree",
159
+ { workspaceId, rootId, maxDepth },
160
+ ANNOTATION_READ_PERMISSIONS,
161
+ call,
162
+ ),
134
163
  );
135
164
  },
136
165
  };
@@ -0,0 +1,124 @@
1
+ /**
2
+ * A real VehicleClient bridge to the Lector daemon's /vehicle/* HTTP surface (see Lector's own
3
+ * daemon.ts, which mounts @danypops/vehicle-server/http's createVehicleHttpApp additively
4
+ * alongside the legacy /api/v1/ops endpoint). Used by whichever pi-lector tool actions have
5
+ * migrated onto Vehicle's operation-descriptor style so far (git status/log/diff today -- see
6
+ * git/operations.ts) -- distinct from lector-client.ts's own LectorClient/OperationName
7
+ * dispatch, which every other tool action still uses and will keep using until its own backing
8
+ * operation migrates too.
9
+ *
10
+ * Same pattern already proven for web-spider's web_category (pi-web-spider's
11
+ * invokeWebSpiderVehicleOperation) -- see also @danypops/vehicle-client-pi's own
12
+ * invokeVehicleOperation() doc comment: a consumer whose tool deliberately consolidates several
13
+ * operations behind one action parameter (Anthropic's own tool-design guidance) gets the same
14
+ * cross-cutting policy layer (activity broadcasting, the local /safety ask gate, the server
15
+ * approval-required retry dance, idempotency-key/correlationId derivation) a
16
+ * registerVehicleTools()-registered tool gets automatically, without regressing its own
17
+ * consolidated shape into one Pi tool per operation.
18
+ *
19
+ * Deliberately does NOT auto-spawn the daemon, matching lector-client.ts's own stated
20
+ * convention: a clear "start it with `lector serve`" error beats guessing at a lifecycle the
21
+ * user didn't ask for.
22
+ */
23
+ import { resolveLectorDaemonConnection } from "@danypops/lector";
24
+ import { createReconnectingVehicleClient, daemonInstanceIdentity } from "@danypops/vehicle-client/daemon-client";
25
+ import { RemoteVehicleClient } from "@danypops/vehicle-client/http";
26
+ import { invokeVehicleOperation } from "@danypops/vehicle-client-pi";
27
+ import type { VehicleClient } from "@danypops/vehicle-core";
28
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
29
+
30
+ type VehicleClientConnector = () => Promise<VehicleClient>;
31
+
32
+ function connectLectorVehicleClient(): Promise<VehicleClient> {
33
+ const { host, port, token } = resolveLectorDaemonConnection();
34
+ return Promise.resolve(new RemoteVehicleClient({ baseUrl: `http://${host}:${port}`, token }));
35
+ }
36
+
37
+ function resolveLectorVehicleIdentity() {
38
+ try {
39
+ const { host, port } = resolveLectorDaemonConnection();
40
+ return daemonInstanceIdentity(`http://${host}:${port}`);
41
+ } catch {
42
+ return daemonInstanceIdentity("unresolved");
43
+ }
44
+ }
45
+
46
+ let connector: VehicleClientConnector = connectLectorVehicleClient;
47
+ let vehicleClient: VehicleClient = createReconnectingVehicleClient(() => connector(), {
48
+ resolveIdentity: resolveLectorVehicleIdentity,
49
+ // connectRetry:true (vehicle-client's own bounded background retry budget) covers a daemon
50
+ // that crashed and is mid systemd-restart -- without it, the very first call during that
51
+ // window fails immediately instead of waiting the restart out, same policy as
52
+ // lector-client.ts's own createRetryingLectorClient.
53
+ connectRetry: true,
54
+ });
55
+
56
+ export function setLectorVehicleClientConnectorForTests(value: VehicleClientConnector): void {
57
+ connector = value;
58
+ vehicleClient = createReconnectingVehicleClient(() => connector());
59
+ }
60
+
61
+ export function resetLectorVehicleClientForTests(): void {
62
+ connector = connectLectorVehicleClient;
63
+ vehicleClient = createReconnectingVehicleClient(() => connector(), { resolveIdentity: resolveLectorVehicleIdentity, connectRetry: true });
64
+ }
65
+
66
+ /**
67
+ * Everything about a real Pi tool call needed to invoke a Vehicle operation through it, except
68
+ * which permissions this call declares -- that's operation-specific (matches whichever
69
+ * *_PERMISSIONS constant the backing operation's own registration declared server-side), so it's
70
+ * a separate invokeLectorVehicleOperation() argument, not part of this reusable per-tool-call
71
+ * envelope.
72
+ *
73
+ * Deliberately has no `onUpdate` field: invokeVehicleOperation()'s own progress callback is typed
74
+ * against PiVehicleToolDetails (vehicle-client-pi's generic {vehicle, progress}/{vehicle,
75
+ * presentation} shape), never a specific tool's own XToolDetails -- none of git/
76
+ * symbol_annotations/repo_cache/external_search/mutation_history's operations are long-running
77
+ * enough to emit real mid-flight progress in practice, and forwarding a caller's own
78
+ * differently-shaped onUpdate here would need an unsafe cast to paper over a real type mismatch,
79
+ * not just a lint complaint.
80
+ */
81
+ export interface LectorVehicleCall {
82
+ readonly toolName: string;
83
+ readonly toolCallId: string;
84
+ readonly signal?: AbortSignal;
85
+ readonly context: ExtensionContext;
86
+ }
87
+
88
+ /**
89
+ * Dispatches one already-VehicleRegistry-backed Lector operation through vehicle-client-pi's
90
+ * cross-cutting policy layer instead of a bare lectorClient().call(), which would forfeit all of
91
+ * it. Fetches the manifest on every call rather than caching it: these are low-frequency,
92
+ * user-driven tool actions (not a hot loop), and a fresh manifest fetch is one cheap extra round
93
+ * trip that also self-heals if the daemon's own operation set ever changes between calls.
94
+ *
95
+ * Generic over T (the caller's own known output shape for this specific operation) so every call
96
+ * site gets a properly typed result without its own unsafe cast -- the one unavoidable narrowing
97
+ * (result.details.output is typed unknown; see PiVehicleToolDetails) happens exactly once, here,
98
+ * trusted because the caller already knows which operation it invoked and what shape that
99
+ * operation's own registered output schema produces -- same trust boundary Lector's own
100
+ * dispatch-through-registry.ts documents for its identical generic-unwrap seam.
101
+ */
102
+ export async function invokeLectorVehicleOperation<T>(
103
+ operationName: string,
104
+ input: Record<string, unknown>,
105
+ permissions: readonly string[],
106
+ call: LectorVehicleCall,
107
+ ): Promise<T> {
108
+ const manifest = await vehicleClient.manifest();
109
+ const descriptor = manifest.operations.find((op) => op.name === operationName);
110
+ if (!descriptor) throw new Error(`Lector Vehicle manifest has no operation named '${operationName}'`);
111
+ const result = await invokeVehicleOperation({
112
+ client: vehicleClient,
113
+ manifest,
114
+ descriptor,
115
+ toolName: call.toolName,
116
+ toolCallId: call.toolCallId,
117
+ input,
118
+ context: call.context,
119
+ signal: call.signal,
120
+ options: { permissions },
121
+ });
122
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see this function's own doc comment.
123
+ return result.details.output as T;
124
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-lector",
3
- "version": "0.12.9",
3
+ "version": "0.12.10",
4
4
  "description": "Pi host adapter for Lector: overrides read/write/edit with a daemon-backed, hash-guarded filesystem",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,15 +18,18 @@
18
18
  "peerDependencies": {
19
19
  "@earendil-works/pi-coding-agent": "*",
20
20
  "@earendil-works/pi-tui": "*",
21
- "typebox": "*"
21
+ "typebox": "*",
22
+ "@danypops/vehicle-client-pi": "^0.41.3"
22
23
  },
23
24
  "dependencies": {
24
- "@danypops/vehicle-client": "^0.8.2",
25
- "@danypops/lector": "^0.18.0",
25
+ "@danypops/vehicle-client": "^0.10.0",
26
+ "@danypops/vehicle-core": "^0.17.0",
27
+ "@danypops/lector": "^0.19.1",
26
28
  "malevich-tui-components": "^0.25.0",
27
29
  "picomatch": "^4.0.5"
28
30
  },
29
31
  "devDependencies": {
32
+ "@danypops/vehicle-client-pi": "^0.41.3",
30
33
  "@danypops/pi-extension-harness": "^0.2.0",
31
34
  "@danypops/pi-tui-harness": "^0.0.1",
32
35
  "@earendil-works/pi-ai": "^0.81.1",