@cassiomc1/forgeloop 1.10.1 → 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 (92) 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 +13 -1
  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 +21 -18
  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/ADVISORY_CONTEXT.md +24 -0
  22. package/docs/AGENT_PROTOCOL_SUMMARY.md +8 -1
  23. package/docs/CLI_REFERENCE.md +114 -2
  24. package/docs/DOCUMENTATION_GUIDE.md +10 -5
  25. package/docs/GETTING_STARTED.md +17 -0
  26. package/docs/MCP.md +16 -1
  27. package/docs/PACKAGE_CONTENTS.md +12 -1
  28. package/docs/PERSISTENT_SEARCH_TRANSPORT.md +289 -0
  29. package/docs/RECIPES.md +30 -0
  30. package/docs/RELEASE_CHECKLIST.md +36 -2
  31. package/docs/REPOSITORY_INDEX.md +553 -0
  32. package/docs/RIPWIRE_ADAPTER.md +189 -0
  33. package/docs/TROUBLESHOOTING.md +147 -0
  34. package/docs/UNIVERSAL_INTEGRATION.md +30 -0
  35. package/docs/diagrams/README.md +19 -0
  36. package/package.json +12 -2
  37. package/scripts/update-tgrep-manifest.mjs +86 -0
  38. package/scripts/verify-tgrep-manifest.mjs +17 -0
  39. package/src/adapters/ripwire/normalize.js +352 -0
  40. package/src/adapters/ripwire/process.js +248 -0
  41. package/src/adapters/ripwire/provider.js +245 -0
  42. package/src/cli.js +35 -0
  43. package/src/commands/doctor.js +76 -1
  44. package/src/commands/index-rebuild.js +1 -0
  45. package/src/commands/index-setup.js +1 -0
  46. package/src/commands/index-start.js +1 -0
  47. package/src/commands/index-status.js +1 -0
  48. package/src/commands/index-stop.js +1 -0
  49. package/src/commands/init.js +39 -1
  50. package/src/commands/repository-index.js +111 -0
  51. package/src/commands/search.js +1 -0
  52. package/src/commands/update.js +32 -4
  53. package/src/core/advisory-context/service.js +36 -14
  54. package/src/core/cli-command-definitions.js +97 -3
  55. package/src/core/command-executors.js +35 -2
  56. package/src/core/command-input.js +23 -0
  57. package/src/core/error-codes.js +195 -0
  58. package/src/core/filesystem.js +10 -1
  59. package/src/core/handoff-acceptance.js +10 -1
  60. package/src/core/integration-invocation-policy.js +27 -0
  61. package/src/core/integration-resources.js +16 -1
  62. package/src/core/protocol-info.js +23 -0
  63. package/src/core/work-state.js +15 -6
  64. package/src/integration.d.ts +101 -0
  65. package/src/integration.js +22 -0
  66. package/src/persistent-transport/client.js +293 -0
  67. package/src/persistent-transport/constants.js +24 -0
  68. package/src/persistent-transport/errors.js +38 -0
  69. package/src/persistent-transport/framing.js +61 -0
  70. package/src/persistent-transport/lifecycle.js +116 -0
  71. package/src/persistent-transport/ownership.js +184 -0
  72. package/src/persistent-transport/paths.js +31 -0
  73. package/src/persistent-transport/protocol.js +95 -0
  74. package/src/persistent-transport/server.js +256 -0
  75. package/src/persistent-transport/state.js +49 -0
  76. package/src/repository-index/args.js +59 -0
  77. package/src/repository-index/binary-manager.js +413 -0
  78. package/src/repository-index/constants.js +45 -0
  79. package/src/repository-index/errors.js +38 -0
  80. package/src/repository-index/lifecycle.js +17 -0
  81. package/src/repository-index/lock.js +113 -0
  82. package/src/repository-index/manifest.js +132 -0
  83. package/src/repository-index/metrics.js +30 -0
  84. package/src/repository-index/normalize-json.js +187 -0
  85. package/src/repository-index/paths.js +39 -0
  86. package/src/repository-index/platform.js +20 -0
  87. package/src/repository-index/process.js +140 -0
  88. package/src/repository-index/readiness.js +62 -0
  89. package/src/repository-index/search.js +262 -0
  90. package/src/repository-index/server.js +432 -0
  91. package/src/repository-index/status.js +397 -0
  92. package/src/repository-index/tgrep-manifest.json +38 -0
@@ -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,
@@ -307,12 +307,21 @@ export async function mutateWorkState(target, { expectedRevision, packageRoot =
307
307
  throw error;
308
308
  }
309
309
  if (!(await getTaskTransaction(target))) {
310
- return withTaskTransaction({
311
- target,
312
- taskId: taskId ?? "legacy-work-state",
313
- lockTaskId: taskId ?? "legacy-work-state",
314
- operation: "mutate-work-state",
315
- }, async () => mutateWorkState(target, { expectedRevision, packageRoot, taskId, statePath }, updater));
310
+ try {
311
+ return await withTaskTransaction({
312
+ target,
313
+ taskId: taskId ?? "legacy-work-state",
314
+ lockTaskId: taskId ?? "legacy-work-state",
315
+ operation: "mutate-work-state",
316
+ }, async () => mutateWorkState(target, { expectedRevision, packageRoot, taskId, statePath }, updater));
317
+ } catch (error) {
318
+ if (error?.code === "E_TASK_LOCKED") {
319
+ const revisionError = new WorkStateError("Work state revision is being mutated concurrently");
320
+ revisionError.code = "E_STATE_REVISION_CONFLICT";
321
+ throw revisionError;
322
+ }
323
+ throw error;
324
+ }
316
325
  }
317
326
  const current = await readWorkState(target, { packageRoot, taskId, statePath });
318
327
  if (!current || (current.revision ?? 0) !== expectedRevision) {
@@ -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;
@@ -108,6 +190,14 @@ export interface ForgeLoopAdvisoryContextProvider {
108
190
 
109
191
  export type ForgeLoopAdvisoryContextProviderFactory = () => ForgeLoopAdvisoryContextProvider | Promise<ForgeLoopAdvisoryContextProvider>;
110
192
 
193
+ /** Options for the optional, host-injected Ripwire advisory adapter. */
194
+ export interface ForgeLoopRipwireProviderOptions {
195
+ /** Absolute path selected by the host; ForgeLoop does not discover it. */
196
+ executablePath: string;
197
+ /** Exact version string qualified by the host and checked lazily at recall time. */
198
+ expectedVersion: string;
199
+ }
200
+
111
201
  export interface ForgeLoopNormalizedAdvisoryContextResult {
112
202
  provider: {
113
203
  id: string;
@@ -257,6 +347,14 @@ export declare function getForgeLoopCapabilities(input?: { packageVersion?: stri
257
347
  export declare function classifyForgeLoopInvocation(command: string, input?: ForgeLoopCommandInput): ForgeLoopInvocationClassification;
258
348
  export declare function readForgeLoopIntegrationResource(uri: string, input?: Record<string, unknown>): Promise<Record<string, unknown>>;
259
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>>;
260
358
  export declare function createForgeLoopContext(input?: Record<string, unknown>): ForgeLoopContext;
261
359
  export declare function recallAdvisoryContext(input: {
262
360
  target: string;
@@ -269,6 +367,9 @@ export declare function recallAdvisoryContext(input: {
269
367
  timeoutMs?: number;
270
368
  runtimeContext?: ForgeLoopContext | Record<string, unknown>;
271
369
  }): Promise<ForgeLoopNormalizedAdvisoryContextResult>;
370
+ export declare function createRipwireAdvisoryContextProvider(
371
+ options: ForgeLoopRipwireProviderOptions,
372
+ ): ForgeLoopAdvisoryContextProvider;
272
373
  export declare const ADVISORY_CONTEXT_LIMITS: Readonly<Record<string, number>>;
273
374
  export declare const ADVISORY_CONTEXT_TRUST: Readonly<Record<string, unknown>>;
274
375
  export declare function normalizeAdvisoryRecallOptions(input?: Partial<ForgeLoopAdvisoryRecallOptions>): ForgeLoopAdvisoryRecallOptions;
@@ -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,
@@ -81,6 +102,7 @@ export {
81
102
  VERIFICATION_ISOLATION_MODES,
82
103
  } from "./core/verification-execution.js";
83
104
  export { recallAdvisoryContext } from "./core/advisory-context/service.js";
105
+ export { createRipwireAdvisoryContextProvider } from "./adapters/ripwire/provider.js";
84
106
  export {
85
107
  ADVISORY_CONTEXT_LIMITS,
86
108
  ADVISORY_CONTEXT_TRUST,
@@ -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
+ }