@cassiomc1/forgeloop 1.10.2 → 1.11.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.
Files changed (84) hide show
  1. package/.cursor/rules/project-loop.mdc +2 -2
  2. package/.forgeloop/forgeloop.gitignore +1 -0
  3. package/.github/copilot-instructions.md +2 -2
  4. package/AGENTS.md +1 -0
  5. package/AGENT_COMPATIBILITY.md +7 -0
  6. package/CLAUDE.md +1 -0
  7. package/DOCS_INDEX.md +10 -0
  8. package/LOOP_SYSTEM_DESIGN.md +12 -0
  9. package/ORCHESTRATOR_INTEGRATION.md +9 -0
  10. package/PROTOCOL_INTEGRATION.md +17 -0
  11. package/README.md +12 -10
  12. package/THIRD_PARTY_NOTICES.md +11 -0
  13. package/THREAT_MODEL.md +21 -0
  14. package/benchmarks/repository-index/README.md +73 -0
  15. package/benchmarks/repository-index/queries.json +12 -0
  16. package/benchmarks/repository-index/run-hot-path.mjs +142 -0
  17. package/benchmarks/repository-index/run-persistent-transport.mjs +169 -0
  18. package/completions/_forgeloop +7 -1
  19. package/completions/forgeloop.bash +13 -1
  20. package/completions/forgeloop.fish +40 -1
  21. package/docs/AGENT_PROTOCOL_SUMMARY.md +8 -1
  22. package/docs/CLI_REFERENCE.md +114 -2
  23. package/docs/DOCUMENTATION_GUIDE.md +10 -5
  24. package/docs/GETTING_STARTED.md +17 -0
  25. package/docs/MCP.md +16 -1
  26. package/docs/PACKAGE_CONTENTS.md +7 -1
  27. package/docs/PERSISTENT_SEARCH_TRANSPORT.md +289 -0
  28. package/docs/RECIPES.md +30 -0
  29. package/docs/RELEASE_CHECKLIST.md +34 -0
  30. package/docs/REPOSITORY_INDEX.md +553 -0
  31. package/docs/TROUBLESHOOTING.md +147 -0
  32. package/docs/UNIVERSAL_INTEGRATION.md +30 -0
  33. package/docs/diagrams/README.md +10 -0
  34. package/package.json +11 -2
  35. package/scripts/update-tgrep-manifest.mjs +86 -0
  36. package/scripts/verify-tgrep-manifest.mjs +17 -0
  37. package/src/cli.js +35 -0
  38. package/src/commands/doctor.js +76 -1
  39. package/src/commands/index-rebuild.js +1 -0
  40. package/src/commands/index-setup.js +1 -0
  41. package/src/commands/index-start.js +1 -0
  42. package/src/commands/index-status.js +1 -0
  43. package/src/commands/index-stop.js +1 -0
  44. package/src/commands/init.js +39 -1
  45. package/src/commands/repository-index.js +111 -0
  46. package/src/commands/search.js +1 -0
  47. package/src/commands/update.js +32 -4
  48. package/src/core/cli-command-definitions.js +97 -3
  49. package/src/core/command-executors.js +35 -2
  50. package/src/core/command-input.js +23 -0
  51. package/src/core/error-codes.js +195 -0
  52. package/src/core/filesystem.js +10 -1
  53. package/src/core/integration-invocation-policy.js +27 -0
  54. package/src/core/integration-resources.js +16 -1
  55. package/src/core/protocol-info.js +23 -0
  56. package/src/integration.d.ts +90 -0
  57. package/src/integration.js +21 -0
  58. package/src/persistent-transport/client.js +293 -0
  59. package/src/persistent-transport/constants.js +24 -0
  60. package/src/persistent-transport/errors.js +38 -0
  61. package/src/persistent-transport/framing.js +61 -0
  62. package/src/persistent-transport/lifecycle.js +116 -0
  63. package/src/persistent-transport/ownership.js +184 -0
  64. package/src/persistent-transport/paths.js +31 -0
  65. package/src/persistent-transport/protocol.js +95 -0
  66. package/src/persistent-transport/server.js +256 -0
  67. package/src/persistent-transport/state.js +49 -0
  68. package/src/repository-index/args.js +59 -0
  69. package/src/repository-index/binary-manager.js +413 -0
  70. package/src/repository-index/constants.js +45 -0
  71. package/src/repository-index/errors.js +38 -0
  72. package/src/repository-index/lifecycle.js +17 -0
  73. package/src/repository-index/lock.js +113 -0
  74. package/src/repository-index/manifest.js +132 -0
  75. package/src/repository-index/metrics.js +30 -0
  76. package/src/repository-index/normalize-json.js +187 -0
  77. package/src/repository-index/paths.js +39 -0
  78. package/src/repository-index/platform.js +20 -0
  79. package/src/repository-index/process.js +140 -0
  80. package/src/repository-index/readiness.js +62 -0
  81. package/src/repository-index/search.js +262 -0
  82. package/src/repository-index/server.js +432 -0
  83. package/src/repository-index/status.js +397 -0
  84. package/src/repository-index/tgrep-manifest.json +38 -0
@@ -129,6 +129,7 @@ export async function assertSafePath(root, relativePath) {
129
129
  }
130
130
 
131
131
  let existing = destination;
132
+ let relativeExisting = path.normalize(relativePath);
132
133
  while (true) {
133
134
  try {
134
135
  const info = await lstatWithTransientWindowsRetry(existing);
@@ -136,7 +137,14 @@ export async function assertSafePath(root, relativePath) {
136
137
  throw new Error(`Path uses a symlink inside target directory: ${relativePath}`);
137
138
  }
138
139
  const resolvedRoot = await realpathWithTransientWindowsRetry(absoluteRoot);
139
- const resolvedExisting = await realpathWithTransientWindowsRetry(existing);
140
+ // Resolve the comparison path from the canonical root. Windows can
141
+ // return different equivalent spellings (for example, short names or
142
+ // extended-length prefixes) for the root and a child path when those
143
+ // paths are canonicalized independently. Reconstructing the child from
144
+ // the canonical root keeps the comparison in one namespace without
145
+ // weakening the realpath containment check for junctions or symlinks.
146
+ const canonicalExisting = path.resolve(resolvedRoot, relativeExisting);
147
+ const resolvedExisting = await realpathWithTransientWindowsRetry(canonicalExisting);
140
148
  if (!isPathWithin(resolvedRoot, resolvedExisting)) {
141
149
  throw new Error(`Path escapes target directory: ${relativePath}`);
142
150
  }
@@ -146,6 +154,7 @@ export async function assertSafePath(root, relativePath) {
146
154
  const parent = path.dirname(existing);
147
155
  if (parent === existing) throw new Error(`Path does not resolve inside target directory: ${relativePath}`);
148
156
  existing = parent;
157
+ relativeExisting = path.dirname(relativeExisting);
149
158
  }
150
159
  }
151
160
  }
@@ -33,6 +33,7 @@ const READ_ONLY_COMMANDS = Object.freeze(new Set([
33
33
  "workspace-status", "handoff-list", "handoff-show", "responsibility-status",
34
34
  "attestation-verify", "attestation-status", "attestation-verify-range",
35
35
  "quality-status",
36
+ "index-status", "search",
36
37
  ]));
37
38
 
38
39
  const LOOP_MUTATION_COMMANDS = Object.freeze(new Set([
@@ -73,6 +74,10 @@ const STATIC_RISK_CLASSES = Object.freeze({
73
74
  "migrate-protocol": INTEGRATION_RISK_CLASSES.MAINTENANCE,
74
75
  "clear-state": INTEGRATION_RISK_CLASSES.MAINTENANCE,
75
76
  doctor: INTEGRATION_RISK_CLASSES.MAINTENANCE,
77
+ "index-setup": INTEGRATION_RISK_CLASSES.MAINTENANCE,
78
+ "index-start": INTEGRATION_RISK_CLASSES.MAINTENANCE,
79
+ "index-stop": INTEGRATION_RISK_CLASSES.MAINTENANCE,
80
+ "index-rebuild": INTEGRATION_RISK_CLASSES.MAINTENANCE,
76
81
  "policy-discover": INTEGRATION_RISK_CLASSES.MAINTENANCE,
77
82
  baseline: INTEGRATION_RISK_CLASSES.MAINTENANCE,
78
83
  "task-unlock": INTEGRATION_RISK_CLASSES.MAINTENANCE,
@@ -249,6 +254,27 @@ export function getForgeLoopCapabilities({ packageVersion = null } = {}) {
249
254
  modes: [...VERIFICATION_ISOLATION_MODES],
250
255
  protocolProjectRootSeparateFromExecutionCwd: true,
251
256
  },
257
+ repositoryIndex: {
258
+ version: 1,
259
+ required: true,
260
+ providerNeutral: true,
261
+ implementation: "microsoft/tgrep",
262
+ engineVersion: "1.0.3",
263
+ engineManagedByForgeLoop: true,
264
+ commands: ["search", "index-setup", "index-start", "index-stop", "index-status", "index-rebuild"],
265
+ resource: "repository/index-status",
266
+ managedBinary: true,
267
+ noPathFallback: true,
268
+ supports: {
269
+ regex: true,
270
+ fixedStrings: true,
271
+ glob: true,
272
+ fileType: true,
273
+ context: true,
274
+ structuredOutput: true,
275
+ liveIndex: true,
276
+ },
277
+ },
252
278
  },
253
279
  commands,
254
280
  resources: [
@@ -271,6 +297,7 @@ export function getForgeLoopCapabilities({ packageVersion = null } = {}) {
271
297
  { name: "task/context", scope: "TASK" },
272
298
  { name: "task/evaluations", scope: "TASK" },
273
299
  { name: "project/capability-policy", scope: "PROJECT" },
300
+ { name: "repository/index-status", scope: "PROJECT" },
274
301
  ],
275
302
  };
276
303
  }
@@ -20,6 +20,7 @@ import { readVerificationScope } from "./verification-scope.js";
20
20
  import { resolveAttestationStatus } from "./attestation.js";
21
21
  import { buildExecutionProfileContext } from "./execution-profile-context.js";
22
22
  import { projectStructuralQualityStatus } from "./structural-quality/service.js";
23
+ import { getRepositoryIndexStatus } from "../repository-index/status.js";
23
24
 
24
25
  /**
25
26
  * Canonical integration resource allowlist.
@@ -87,6 +88,7 @@ export const INTEGRATION_RESOURCE_DEFINITIONS = Object.freeze({
87
88
  "task/context": Object.freeze({ scope: "TASK", description: "Read-only profile-aware task context with bounded presentation policy." }),
88
89
  "task/evaluations": Object.freeze({ scope: "TASK", description: "Persisted trajectory evaluations for one task." }),
89
90
  "project/capability-policy": Object.freeze({ scope: "PROJECT", description: "Project capability policy, never host authority." }),
91
+ "repository/index-status": Object.freeze({ scope: "PROJECT", description: "Provider-neutral repository-index health and owned-server status." }),
90
92
  });
91
93
 
92
94
  function ownershipProjection(projection) {
@@ -103,7 +105,7 @@ function ownershipProjection(projection) {
103
105
  };
104
106
  }
105
107
 
106
- export async function readForgeLoopIntegrationResource(uri, {
108
+ async function readForgeLoopIntegrationResourceCore(uri, {
107
109
  projectPath = ".",
108
110
  packageRoot = undefined,
109
111
  packageVersion = null,
@@ -288,3 +290,16 @@ export async function readForgeLoopIntegrationResource(uri, {
288
290
  const continuity = await runContinuity({ target: projectPath, packageRoot, taskId });
289
291
  return { uri, taskId, data: continuity };
290
292
  }
293
+
294
+ export async function readForgeLoopIntegrationResource(uri, options = {}) {
295
+ if (uri === "repository/index-status") {
296
+ return {
297
+ uri,
298
+ data: await getRepositoryIndexStatus(options.projectPath ?? ".", {
299
+ packageRoot: options.packageRoot,
300
+ ...(options.repositoryIndexOptions ?? {}),
301
+ }),
302
+ };
303
+ }
304
+ return readForgeLoopIntegrationResourceCore(uri, options);
305
+ }
@@ -47,6 +47,29 @@ export function protocolInfo({ packageVersion = null } = {}) {
47
47
  structuredCommandRuntime: true,
48
48
  canonicalResources: true,
49
49
  },
50
+ repositoryIndex: {
51
+ version: 1,
52
+ required: true,
53
+ providerNeutral: true,
54
+ implementation: "microsoft/tgrep",
55
+ engineVersion: "1.0.3",
56
+ engineManagedByForgeLoop: true,
57
+ commands: ["search", "index-setup", "index-start", "index-stop", "index-status", "index-rebuild"],
58
+ resource: "repository/index-status",
59
+ managedBinary: true,
60
+ path: ".forgeloop/repository-index/tgrep",
61
+ lifecycleStatePath: ".forgeloop/repository-index/engine-state.json",
62
+ noPathFallback: true,
63
+ supports: {
64
+ regex: true,
65
+ fixedStrings: true,
66
+ glob: true,
67
+ fileType: true,
68
+ context: true,
69
+ structuredOutput: true,
70
+ liveIndex: true,
71
+ },
72
+ },
50
73
  executionHistory: {
51
74
  version: 1,
52
75
  supported: true,
@@ -19,6 +19,88 @@ export interface ForgeLoopCommandInput {
19
19
  commandArgv?: string[];
20
20
  }
21
21
 
22
+ export interface RepositorySearchRequest {
23
+ pattern: string;
24
+ globs?: string[];
25
+ types?: string[];
26
+ fixedStrings?: boolean;
27
+ ignoreCase?: boolean;
28
+ smartCase?: boolean;
29
+ wordRegexp?: boolean;
30
+ context?: number | null;
31
+ beforeContext?: number | null;
32
+ afterContext?: number | null;
33
+ maxCount?: number | null;
34
+ filesWithMatches?: boolean;
35
+ stats?: boolean;
36
+ /** Explicit host-selected binary override; ForgeLoop never discovers PATH executables. */
37
+ binaryPath?: string;
38
+ /** Preloaded archive used by the managed-engine provisioning path. */
39
+ assetPath?: string;
40
+ }
41
+
42
+ export interface RepositorySearchSubmatch {
43
+ start: number;
44
+ end: number;
45
+ match: string | null;
46
+ }
47
+
48
+ export interface RepositorySearchMatch {
49
+ path: string;
50
+ line: number;
51
+ column: number | null;
52
+ offset: number | null;
53
+ text: string;
54
+ submatches: readonly RepositorySearchSubmatch[];
55
+ }
56
+
57
+ export interface RepositorySearchResult {
58
+ schemaVersion: 1;
59
+ query: RepositorySearchRequest & { globs: readonly string[]; types: readonly string[] };
60
+ repositoryIndex: { engine: string; engineVersion: string | null; indexed: boolean; server: boolean };
61
+ matches: readonly RepositorySearchMatch[];
62
+ contexts: readonly RepositorySearchMatch[];
63
+ files: readonly string[];
64
+ stats: Readonly<Record<string, number>>;
65
+ metrics: {
66
+ queryDurationMs: number;
67
+ nativeDurationMs: number;
68
+ matchCount: number;
69
+ matchedFileCount: number;
70
+ engine: string;
71
+ engineVersion: string | null;
72
+ serverUsed: boolean;
73
+ exitCode: number;
74
+ ignoredNativeEvents: number;
75
+ bytesSearched?: number;
76
+ matchedLines?: number;
77
+ };
78
+ }
79
+
80
+ export interface RepositoryIndexStatus {
81
+ schemaVersion: 1;
82
+ required: true;
83
+ engine: "tgrep";
84
+ engineVersion: string | null;
85
+ managedBinary: boolean;
86
+ overridden: boolean;
87
+ index: {
88
+ present: boolean;
89
+ complete: boolean;
90
+ files: number | null;
91
+ trigrams: number | null;
92
+ createdAt: number | null;
93
+ updatedAt: number | null;
94
+ };
95
+ policy: { maxFileSize: string | number; maxCpuPercent: number; watcherQueueCap: number; autoSaveMutations: number };
96
+ server: { running: boolean; owned: boolean; pid: number | null; port: number | null; watcher: string; indexing: string; files: number | null };
97
+ health: "READY" | "INDEXING" | "NOT_INITIALIZED" | "ENGINE_MISSING" | "ENGINE_INVALID" | "SERVER_DOWN" | "SERVER_UNHEALTHY" | "ERROR";
98
+ diagnostics: readonly { code: string; message: string }[];
99
+ }
100
+
101
+ export declare function repositorySearch(input: RepositorySearchRequest & { projectPath?: string }): Promise<RepositorySearchResult>;
102
+ export declare function repositoryIndexStatus(input?: { projectPath?: string; binaryPath?: string; assetPath?: string }): Promise<RepositoryIndexStatus>;
103
+
22
104
  export interface ForgeLoopCommandEnvelope<T = unknown> {
23
105
  ok: boolean;
24
106
  command: string | null;
@@ -265,6 +347,14 @@ export declare function getForgeLoopCapabilities(input?: { packageVersion?: stri
265
347
  export declare function classifyForgeLoopInvocation(command: string, input?: ForgeLoopCommandInput): ForgeLoopInvocationClassification;
266
348
  export declare function readForgeLoopIntegrationResource(uri: string, input?: Record<string, unknown>): Promise<Record<string, unknown>>;
267
349
  export declare function resolveForgeLoopProjectRoot(projectPath?: string, input?: { cwd?: string }): Promise<string>;
350
+ export declare function searchRepository(request: RepositorySearchRequest & { repoRoot: string }): Promise<RepositorySearchResult>;
351
+ export declare function searchRepository(projectPath: string, request: RepositorySearchRequest): Promise<RepositorySearchResult>;
352
+ export declare function getRepositoryIndexStatus(projectPath: string, options?: Record<string, unknown>): Promise<RepositoryIndexStatus>;
353
+ export declare function setupRepositoryIndex(projectPath: string, options?: Record<string, unknown>): Promise<Record<string, unknown>>;
354
+ export declare function startRepositoryIndexServer(projectPath: string, options?: Record<string, unknown>): Promise<Record<string, unknown>>;
355
+ export declare function stopRepositoryIndexServer(projectPath: string, options?: Record<string, unknown>): Promise<Record<string, unknown>>;
356
+ export declare function rebuildRepositoryIndex(projectPath: string, options?: Record<string, unknown>): Promise<Record<string, unknown>>;
357
+ export declare function restartRepositoryIndexServer(projectPath: string, options?: Record<string, unknown>): Promise<Record<string, unknown>>;
268
358
  export declare function createForgeLoopContext(input?: Record<string, unknown>): ForgeLoopContext;
269
359
  export declare function recallAdvisoryContext(input: {
270
360
  target: string;
@@ -46,6 +46,27 @@ export {
46
46
  };
47
47
 
48
48
  export { createForgeLoopContext } from "./core/runtime-context.js";
49
+ export { searchRepository } from "./repository-index/search.js";
50
+ export { getRepositoryIndexStatus } from "./repository-index/status.js";
51
+ export {
52
+ rebuildRepositoryIndex,
53
+ restartRepositoryIndexServer,
54
+ setupRepositoryIndex,
55
+ startRepositoryIndexServer,
56
+ stopRepositoryIndexServer,
57
+ } from "./repository-index/server.js";
58
+
59
+ /** Stable Integration API operation for provider-neutral repository search. */
60
+ export async function repositorySearch({ projectPath = ".", ...request } = {}) {
61
+ const { searchRepository } = await import("./repository-index/search.js");
62
+ return searchRepository(projectPath, request);
63
+ }
64
+
65
+ /** Stable read-only Integration API projection for repository-index health. */
66
+ export async function repositoryIndexStatus({ projectPath = ".", ...options } = {}) {
67
+ const { getRepositoryIndexStatus } = await import("./repository-index/status.js");
68
+ return getRepositoryIndexStatus(projectPath, options);
69
+ }
49
70
  export {
50
71
  assertStructuralQualityProvider,
51
72
  createStructuralQualityProviderRegistry,
@@ -0,0 +1,293 @@
1
+ import net from "node:net";
2
+ import path from "node:path";
3
+ import { readFile, realpath } from "node:fs/promises";
4
+
5
+ import { getPackageRoot } from "../core/templates.js";
6
+ import { acquirePersistentTransportStartupLock, cleanPersistentTransportState, getPersistentTransportStatus as getLifecycleStatus, inspectPersistentTransport, startPersistentSearchHost } from "./lifecycle.js";
7
+ import { PERSISTENT_TRANSPORT_DEFAULTS, PERSISTENT_TRANSPORT_PROTOCOL_VERSION } from "./constants.js";
8
+ import { encodeFrame, FrameDecoder, parseFrame } from "./framing.js";
9
+ import { assertSearchParams, createRequest, projectSearchQuery, validateResponse } from "./protocol.js";
10
+ import { PERSISTENT_TRANSPORT_ERROR_CODES, isPersistentTransportError, persistentTransportError } from "./errors.js";
11
+ import { getPersistentTransportPaths } from "./paths.js";
12
+ import { readPersistentTransportState } from "./state.js";
13
+
14
+ function delay(ms) {
15
+ return new Promise((resolve) => setTimeout(resolve, ms));
16
+ }
17
+
18
+ let forgeLoopVersionPromise;
19
+
20
+ async function currentForgeLoopVersion() {
21
+ forgeLoopVersionPromise ??= readFile(path.join(getPackageRoot(), "package.json"), "utf8")
22
+ .then((raw) => JSON.parse(raw).version ?? null);
23
+ return forgeLoopVersionPromise;
24
+ }
25
+
26
+ function isConnectionFailure(error) {
27
+ return isPersistentTransportError(error)
28
+ && [PERSISTENT_TRANSPORT_ERROR_CODES.UNAVAILABLE, PERSISTENT_TRANSPORT_ERROR_CODES.TIMEOUT, PERSISTENT_TRANSPORT_ERROR_CODES.PROTOCOL_MISMATCH, PERSISTENT_TRANSPORT_ERROR_CODES.INVALID_RESPONSE].includes(error.code);
29
+ }
30
+
31
+ function remoteError(response) {
32
+ const error = persistentTransportError(response.error.code, response.error.message, {
33
+ expectedVersion: response.error.expectedVersion,
34
+ actualVersion: response.error.actualVersion,
35
+ });
36
+ error.remote = true;
37
+ return error;
38
+ }
39
+
40
+ function connect(endpoint, timeoutMs) {
41
+ return new Promise((resolve, reject) => {
42
+ let settled = false;
43
+ const socket = net.createConnection(endpoint);
44
+ const timer = setTimeout(() => {
45
+ if (settled) return;
46
+ settled = true;
47
+ socket.destroy();
48
+ reject(persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.TIMEOUT, "Timed out connecting to the persistent search host"));
49
+ }, timeoutMs);
50
+ const finish = (fn, value) => {
51
+ if (settled) return;
52
+ settled = true;
53
+ clearTimeout(timer);
54
+ fn(value);
55
+ };
56
+ socket.once("connect", () => {
57
+ socket.setNoDelay?.(true);
58
+ finish(resolve, socket);
59
+ });
60
+ socket.once("error", (cause) => finish(reject, persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.UNAVAILABLE, `Persistent search host is unavailable: ${cause.code ?? cause.message}`)));
61
+ });
62
+ }
63
+
64
+ function exchange(socket, request, timeoutMs, {
65
+ requestMaxFrameBytes = PERSISTENT_TRANSPORT_DEFAULTS.maxRequestFrameBytes,
66
+ responseMaxFrameBytes = PERSISTENT_TRANSPORT_DEFAULTS.maxResponseFrameBytes,
67
+ } = {}) {
68
+ return new Promise((resolve, reject) => {
69
+ const decoder = new FrameDecoder({ maxFrameBytes: responseMaxFrameBytes });
70
+ let settled = false;
71
+ const timer = setTimeout(() => finish(reject, persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.TIMEOUT, "Timed out waiting for the persistent search host")), timeoutMs);
72
+ const finish = (fn, value) => {
73
+ if (settled) return;
74
+ settled = true;
75
+ clearTimeout(timer);
76
+ socket.off("data", onData);
77
+ socket.off("error", onError);
78
+ socket.off("end", onEnd);
79
+ fn(value);
80
+ };
81
+ const onData = (chunk) => {
82
+ try {
83
+ for (const frame of decoder.push(chunk)) {
84
+ const response = validateResponse(parseFrame(frame), request.id);
85
+ finish(resolve, response);
86
+ return;
87
+ }
88
+ } catch (error) {
89
+ finish(reject, error.code ? error : persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.INVALID_RESPONSE, error.message));
90
+ }
91
+ };
92
+ const onError = (cause) => finish(reject, persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.UNAVAILABLE, `Persistent search host connection failed: ${cause.code ?? cause.message}`));
93
+ const onEnd = () => {
94
+ try { decoder.end(); } catch (error) { finish(reject, error); return; }
95
+ finish(reject, persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.UNAVAILABLE, "Persistent search host closed the connection"));
96
+ };
97
+ socket.on("data", onData);
98
+ socket.once("error", onError);
99
+ socket.once("end", onEnd);
100
+ try {
101
+ socket.write(encodeFrame(request, { maxFrameBytes: requestMaxFrameBytes }));
102
+ } catch (error) {
103
+ finish(reject, error);
104
+ }
105
+ });
106
+ }
107
+
108
+ async function openHost({
109
+ paths,
110
+ timeoutMs,
111
+ state,
112
+ requestMaxFrameBytes = PERSISTENT_TRANSPORT_DEFAULTS.maxRequestFrameBytes,
113
+ responseMaxFrameBytes = PERSISTENT_TRANSPORT_DEFAULTS.maxResponseFrameBytes,
114
+ }) {
115
+ const socket = await connect(paths.endpoint, timeoutMs);
116
+ try {
117
+ const handshakeRequest = createRequest("handshake", { scopeId: paths.scopeId });
118
+ const handshake = await exchange(socket, handshakeRequest, timeoutMs, { requestMaxFrameBytes, responseMaxFrameBytes });
119
+ if (!handshake.ok) throw remoteError(handshake);
120
+ const expectedForgeLoopVersion = await currentForgeLoopVersion();
121
+ if (!handshake.result
122
+ || handshake.result.protocolVersion !== PERSISTENT_TRANSPORT_PROTOCOL_VERSION
123
+ || handshake.result.scopeId !== paths.scopeId
124
+ || handshake.result.forgeLoopVersion !== expectedForgeLoopVersion
125
+ || (state?.forgeLoopVersion && handshake.result.forgeLoopVersion !== state.forgeLoopVersion)) {
126
+ throw persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.PROTOCOL_MISMATCH, "Persistent search host handshake is incompatible", { expectedVersion: PERSISTENT_TRANSPORT_PROTOCOL_VERSION, actualVersion: handshake.result?.protocolVersion });
127
+ }
128
+ if (state?.nonce && handshake.result.nonce !== state.nonce) {
129
+ throw persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.OWNERSHIP_UNVERIFIED, "Persistent search host handshake ownership does not match its state");
130
+ }
131
+ return { socket, handshake: handshake.result };
132
+ } catch (error) {
133
+ socket.destroy();
134
+ throw error;
135
+ }
136
+ }
137
+
138
+ async function pingHost({ homeDirectory, timeoutMs, maxFrameBytes }) {
139
+ const paths = getPersistentTransportPaths({ homeDirectory });
140
+ const { state } = await readPersistentTransportState(paths.statePath);
141
+ if (!state) throw persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.UNAVAILABLE, "Persistent search host has not published its state");
142
+ const connection = await openHost({ paths, timeoutMs, state, responseMaxFrameBytes: maxFrameBytes });
143
+ try {
144
+ return connection.handshake;
145
+ } finally {
146
+ connection.socket.end();
147
+ }
148
+ }
149
+
150
+ export async function pingPersistentSearchHost({ homeDirectory = undefined, timeoutMs = 2_000 } = {}) {
151
+ const resolvedHome = homeDirectory ?? process.env.FORGELOOP_PERSISTENT_TRANSPORT_HOME ?? undefined;
152
+ return pingHost({
153
+ homeDirectory: resolvedHome,
154
+ timeoutMs,
155
+ maxFrameBytes: PERSISTENT_TRANSPORT_DEFAULTS.maxResponseFrameBytes,
156
+ });
157
+ }
158
+
159
+ function searchQuery(request) {
160
+ return projectSearchQuery(request);
161
+ }
162
+
163
+ async function requestSearch({ repositoryRoot, request, options, timeoutMs, maxFrameBytes }) {
164
+ const homeDirectory = options.homeDirectory;
165
+ const paths = getPersistentTransportPaths({ homeDirectory });
166
+ const { state } = await readPersistentTransportState(paths.statePath);
167
+ const params = assertSearchParams({ repository: repositoryRoot, query: searchQuery(request) });
168
+ const connection = await openHost({ paths, timeoutMs, state, responseMaxFrameBytes: maxFrameBytes });
169
+ try {
170
+ const response = await exchange(connection.socket, createRequest("repository.search", params), timeoutMs, {
171
+ responseMaxFrameBytes: maxFrameBytes,
172
+ });
173
+ if (!response.ok) throw remoteError(response);
174
+ return response.result;
175
+ } finally {
176
+ connection.socket.end();
177
+ }
178
+ }
179
+
180
+ async function ensureHost({ homeDirectory, idleTimeoutMs, startupTimeoutMs, env, recover = false }) {
181
+ const deadline = Date.now() + startupTimeoutMs;
182
+ let lock = null;
183
+ while (!lock) {
184
+ lock = await acquirePersistentTransportStartupLock({ homeDirectory, tryOnly: true });
185
+ if (lock) break;
186
+ if (Date.now() >= deadline) {
187
+ throw persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.START_FAILED, "Persistent search host startup coordination exceeded its bounded timeout");
188
+ }
189
+ await delay(Math.min(25, Math.max(1, deadline - Date.now())));
190
+ }
191
+ try {
192
+ let inspection = await inspectPersistentTransport({ homeDirectory });
193
+ if (inspection.status === "OWNERSHIP_UNVERIFIED") {
194
+ throw persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.OWNERSHIP_UNVERIFIED, "A process owns the persistent search endpoint but is not a verified ForgeLoop host");
195
+ }
196
+ if (inspection.status === "STALE") {
197
+ await cleanPersistentTransportState(inspection);
198
+ inspection = await inspectPersistentTransport({ homeDirectory });
199
+ }
200
+ if (inspection.status === "INCOMPATIBLE" || (recover && inspection.status === "READY")) {
201
+ await cleanPersistentTransportState(inspection);
202
+ inspection = await inspectPersistentTransport({ homeDirectory });
203
+ }
204
+ if (inspection.status === "READY") {
205
+ try {
206
+ await pingHost({ homeDirectory, timeoutMs: Math.min(startupTimeoutMs, 2_000), maxFrameBytes: PERSISTENT_TRANSPORT_DEFAULTS.maxResponseFrameBytes });
207
+ return inspection;
208
+ } catch (error) {
209
+ if (!recover && !isConnectionFailure(error)) throw error;
210
+ await cleanPersistentTransportState(inspection);
211
+ }
212
+ }
213
+ await startPersistentSearchHost({ homeDirectory, idleTimeoutMs, env });
214
+ const deadline = Date.now() + startupTimeoutMs;
215
+ while (Date.now() < deadline) {
216
+ try {
217
+ await pingHost({ homeDirectory, timeoutMs: Math.min(1_000, Math.max(100, deadline - Date.now())), maxFrameBytes: PERSISTENT_TRANSPORT_DEFAULTS.maxResponseFrameBytes });
218
+ return await inspectPersistentTransport({ homeDirectory });
219
+ } catch (error) {
220
+ if (!isConnectionFailure(error) && error.code !== PERSISTENT_TRANSPORT_ERROR_CODES.UNAVAILABLE) throw error;
221
+ await delay(25);
222
+ }
223
+ }
224
+ throw persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.START_FAILED, "Persistent search host did not become ready within the startup timeout");
225
+ } finally {
226
+ await lock.release();
227
+ }
228
+ }
229
+
230
+ const startupCoordinators = new Map();
231
+
232
+ function coordinateEnsureHost(options) {
233
+ const key = getPersistentTransportPaths({ homeDirectory: options.homeDirectory }).root;
234
+ const active = startupCoordinators.get(key);
235
+ if (active) return active;
236
+ const pending = ensureHost(options);
237
+ startupCoordinators.set(key, pending);
238
+ const clear = () => {
239
+ if (startupCoordinators.get(key) === pending) startupCoordinators.delete(key);
240
+ };
241
+ pending.then(clear, clear);
242
+ return pending;
243
+ }
244
+
245
+ export async function searchViaPersistentTransport(repositoryRoot, request = {}, options = {}) {
246
+ const canonicalRoot = await realpath(repositoryRoot);
247
+ const homeDirectory = options.homeDirectory ?? process.env.FORGELOOP_PERSISTENT_TRANSPORT_HOME ?? undefined;
248
+ const transportOptions = {
249
+ homeDirectory,
250
+ idleTimeoutMs: options.idleTimeoutMs ?? PERSISTENT_TRANSPORT_DEFAULTS.idleTimeoutMs,
251
+ startupTimeoutMs: options.startupTimeoutMs ?? PERSISTENT_TRANSPORT_DEFAULTS.startupTimeoutMs,
252
+ requestTimeoutMs: options.requestTimeoutMs ?? PERSISTENT_TRANSPORT_DEFAULTS.requestTimeoutMs,
253
+ maxFrameBytes: options.maxResponseFrameBytes ?? PERSISTENT_TRANSPORT_DEFAULTS.maxResponseFrameBytes,
254
+ env: options.env ?? {},
255
+ };
256
+ let lastError;
257
+ for (let attempt = 0; attempt < 2; attempt += 1) {
258
+ try {
259
+ return await requestSearch({ repositoryRoot: canonicalRoot, request, options: transportOptions, timeoutMs: transportOptions.requestTimeoutMs, maxFrameBytes: transportOptions.maxFrameBytes });
260
+ } catch (error) {
261
+ lastError = error;
262
+ if (error.remote || !isConnectionFailure(error)) throw error;
263
+ await coordinateEnsureHost({ ...transportOptions, recover: attempt === 1 });
264
+ }
265
+ }
266
+ throw lastError ?? persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.UNAVAILABLE, "Persistent search host is unavailable");
267
+ }
268
+
269
+ export async function shutdownPersistentSearchHost({ homeDirectory = undefined, timeoutMs = 2_000 } = {}) {
270
+ const resolvedHome = homeDirectory ?? process.env.FORGELOOP_PERSISTENT_TRANSPORT_HOME ?? undefined;
271
+ const inspection = await inspectPersistentTransport({ homeDirectory: resolvedHome });
272
+ if (inspection.status === "NOT_RUNNING") return { status: "NOT_RUNNING" };
273
+ if (!inspection.owned) throw persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.OWNERSHIP_UNVERIFIED, "Persistent search host ownership could not be verified");
274
+ const paths = getPersistentTransportPaths({ homeDirectory: resolvedHome });
275
+ const connection = await openHost({ paths, timeoutMs, state: inspection.state });
276
+ try {
277
+ const response = await exchange(connection.socket, createRequest("transport.shutdown", { nonce: inspection.state.nonce }), timeoutMs);
278
+ if (!response.ok) throw remoteError(response);
279
+ const deadline = Date.now() + timeoutMs;
280
+ while (Date.now() < deadline) {
281
+ const status = await getLifecycleStatus({ homeDirectory: resolvedHome });
282
+ if (status.status === "NOT_RUNNING" || status.status === "STALE") break;
283
+ await delay(25);
284
+ }
285
+ return response.result;
286
+ } finally {
287
+ connection.socket.end();
288
+ }
289
+ }
290
+
291
+ export async function getPersistentTransportStatus(options = {}) {
292
+ return getLifecycleStatus(options);
293
+ }
@@ -0,0 +1,24 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
4
+ export const PERSISTENT_TRANSPORT_PROTOCOL_VERSION = 1;
5
+ export const PERSISTENT_TRANSPORT_SCHEMA_VERSION = 1;
6
+ export const PERSISTENT_TRANSPORT_DEFAULTS = Object.freeze({
7
+ startupTimeoutMs: 15_000,
8
+ requestTimeoutMs: 120_000,
9
+ idleTimeoutMs: 10 * 60 * 1_000,
10
+ maxRequestFrameBytes: 1 * 1024 * 1024,
11
+ maxResponseFrameBytes: 16 * 1024 * 1024,
12
+ maxPatternChars: 4_096,
13
+ });
14
+
15
+ export const PERSISTENT_TRANSPORT_METHODS = Object.freeze([
16
+ "handshake",
17
+ "repository.search",
18
+ "transport.status",
19
+ "transport.shutdown",
20
+ ]);
21
+
22
+ export function getPersistentTransportRoot({ homeDirectory = os.homedir() } = {}) {
23
+ return path.join(homeDirectory, ".forgeloop", "persistent-search");
24
+ }
@@ -0,0 +1,38 @@
1
+ import {
2
+ E_PERSISTENT_TRANSPORT_FRAME_INVALID,
3
+ E_PERSISTENT_TRANSPORT_FRAME_TOO_LARGE,
4
+ E_PERSISTENT_TRANSPORT_HOST_STALE,
5
+ E_PERSISTENT_TRANSPORT_INVALID_REQUEST,
6
+ E_PERSISTENT_TRANSPORT_INVALID_RESPONSE,
7
+ E_PERSISTENT_TRANSPORT_OWNERSHIP_UNVERIFIED,
8
+ E_PERSISTENT_TRANSPORT_PROTOCOL_MISMATCH,
9
+ E_PERSISTENT_TRANSPORT_START_FAILED,
10
+ E_PERSISTENT_TRANSPORT_TIMEOUT,
11
+ E_PERSISTENT_TRANSPORT_UNAVAILABLE,
12
+ } from "../core/error-codes.js";
13
+
14
+ export const PERSISTENT_TRANSPORT_ERROR_CODES = Object.freeze({
15
+ FRAME_INVALID: E_PERSISTENT_TRANSPORT_FRAME_INVALID,
16
+ FRAME_TOO_LARGE: E_PERSISTENT_TRANSPORT_FRAME_TOO_LARGE,
17
+ HOST_STALE: E_PERSISTENT_TRANSPORT_HOST_STALE,
18
+ INVALID_REQUEST: E_PERSISTENT_TRANSPORT_INVALID_REQUEST,
19
+ INVALID_RESPONSE: E_PERSISTENT_TRANSPORT_INVALID_RESPONSE,
20
+ OWNERSHIP_UNVERIFIED: E_PERSISTENT_TRANSPORT_OWNERSHIP_UNVERIFIED,
21
+ PROTOCOL_MISMATCH: E_PERSISTENT_TRANSPORT_PROTOCOL_MISMATCH,
22
+ START_FAILED: E_PERSISTENT_TRANSPORT_START_FAILED,
23
+ TIMEOUT: E_PERSISTENT_TRANSPORT_TIMEOUT,
24
+ UNAVAILABLE: E_PERSISTENT_TRANSPORT_UNAVAILABLE,
25
+ });
26
+
27
+ export function persistentTransportError(code, message, details = {}) {
28
+ const error = new Error(message);
29
+ error.code = code;
30
+ for (const [key, value] of Object.entries(details)) {
31
+ if (value !== undefined) error[key] = value;
32
+ }
33
+ return error;
34
+ }
35
+
36
+ export function isPersistentTransportError(error) {
37
+ return typeof error?.code === "string" && error.code.startsWith("E_PERSISTENT_TRANSPORT_");
38
+ }