@lensagent/core 0.1.0 → 0.1.2

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 (99) hide show
  1. package/README.md +63 -4
  2. package/config/lens.example.jsonc +36 -13
  3. package/dist/adapters/executor-router.d.ts +20 -0
  4. package/dist/adapters/executor-router.js +86 -0
  5. package/dist/adapters/executor.d.ts +9 -3
  6. package/dist/adapters/executor.js +196 -61
  7. package/dist/adapters/lsp.d.ts +2 -1
  8. package/dist/adapters/lsp.js +98 -41
  9. package/dist/adapters/mcp.js +52 -17
  10. package/dist/adapters/revert.d.ts +5 -4
  11. package/dist/adapters/revert.js +6 -64
  12. package/dist/adapters/web.d.ts +9 -0
  13. package/dist/adapters/web.js +244 -0
  14. package/dist/config/files.d.ts +14 -0
  15. package/dist/config/files.js +57 -0
  16. package/dist/config/runtime.d.ts +3 -4
  17. package/dist/config/runtime.js +116 -97
  18. package/dist/config/schema.d.ts +56 -10
  19. package/dist/config/schema.js +48 -8
  20. package/dist/contracts/capability.d.ts +27 -1
  21. package/dist/contracts/domain.d.ts +1 -0
  22. package/dist/contracts/model.d.ts +33 -6
  23. package/dist/contracts/review.d.ts +17 -0
  24. package/dist/contracts/review.js +1 -0
  25. package/dist/core/access-policy.d.ts +40 -0
  26. package/dist/core/access-policy.js +68 -0
  27. package/dist/core/access.d.ts +30 -2
  28. package/dist/core/access.js +159 -26
  29. package/dist/core/context-boundaries.d.ts +25 -0
  30. package/dist/core/context-boundaries.js +106 -0
  31. package/dist/core/context.d.ts +3 -0
  32. package/dist/core/context.js +11 -0
  33. package/dist/core/host.js +261 -173
  34. package/dist/entry/pair.d.ts +1 -0
  35. package/dist/entry/pair.js +14 -0
  36. package/dist/entry/run.js +1 -1
  37. package/dist/execution/lsp-process.d.ts +3 -2
  38. package/dist/execution/lsp-process.js +46 -28
  39. package/dist/http/server.d.ts +5 -0
  40. package/dist/http/server.js +262 -80
  41. package/dist/runtime/agent.d.ts +9 -4
  42. package/dist/runtime/agent.js +361 -286
  43. package/dist/runtime/artifacts.d.ts +13 -1
  44. package/dist/runtime/artifacts.js +137 -10
  45. package/dist/runtime/cache.d.ts +59 -16
  46. package/dist/runtime/cache.js +124 -43
  47. package/dist/runtime/calls.d.ts +1 -1
  48. package/dist/runtime/calls.js +164 -48
  49. package/dist/runtime/capabilities.d.ts +2 -2
  50. package/dist/runtime/capabilities.js +4 -5
  51. package/dist/runtime/cleanup.d.ts +5 -2
  52. package/dist/runtime/cleanup.js +48 -17
  53. package/dist/runtime/compaction.d.ts +9 -0
  54. package/dist/runtime/compaction.js +52 -0
  55. package/dist/runtime/context.d.ts +8 -4
  56. package/dist/runtime/context.js +115 -128
  57. package/dist/runtime/events.d.ts +7 -0
  58. package/dist/runtime/events.js +83 -0
  59. package/dist/runtime/images.d.ts +14 -0
  60. package/dist/runtime/images.js +82 -0
  61. package/dist/runtime/kernel-capabilities.d.ts +7 -1
  62. package/dist/runtime/kernel-capabilities.js +146 -68
  63. package/dist/runtime/model-audit.d.ts +9 -0
  64. package/dist/runtime/model-audit.js +39 -0
  65. package/dist/runtime/model.d.ts +109 -4
  66. package/dist/runtime/model.js +368 -143
  67. package/dist/runtime/observability.d.ts +1 -0
  68. package/dist/runtime/observability.js +60 -15
  69. package/dist/runtime/policy.d.ts +1 -1
  70. package/dist/runtime/policy.js +2 -2
  71. package/dist/runtime/profile.d.ts +2 -1
  72. package/dist/runtime/profile.js +12 -15
  73. package/dist/runtime/projects.d.ts +3 -1
  74. package/dist/runtime/projects.js +38 -17
  75. package/dist/runtime/ptc-worker.d.ts +1 -0
  76. package/dist/runtime/ptc-worker.js +97 -0
  77. package/dist/runtime/ptc.d.ts +16 -0
  78. package/dist/runtime/ptc.js +128 -0
  79. package/dist/runtime/results.d.ts +5 -4
  80. package/dist/runtime/results.js +29 -51
  81. package/dist/runtime/review.d.ts +4 -1
  82. package/dist/runtime/review.js +101 -22
  83. package/dist/runtime/scheduler.js +1 -0
  84. package/dist/runtime/skills.js +34 -5
  85. package/dist/runtime/task-audit.d.ts +21 -0
  86. package/dist/runtime/task-audit.js +122 -0
  87. package/dist/runtime/time.d.ts +9 -0
  88. package/dist/runtime/time.js +18 -0
  89. package/dist/runtime/tokenizer.d.ts +2 -0
  90. package/dist/runtime/tokenizer.js +26 -1
  91. package/dist/store/database.js +26 -1
  92. package/dist/store/event-log.d.ts +1 -0
  93. package/dist/store/event-log.js +22 -5
  94. package/dist/store/schema.d.ts +1 -1
  95. package/dist/store/schema.js +50 -1
  96. package/dist/store/store.d.ts +65 -19
  97. package/dist/store/store.js +986 -188
  98. package/package.json +18 -8
  99. package/proto/executor.proto +69 -55
@@ -1,6 +1,6 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
- import { dirname, isAbsolute, resolve } from 'node:path';
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { dirname, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import * as grpc from '@grpc/grpc-js';
6
6
  import * as protoLoader from '@grpc/proto-loader';
@@ -30,18 +30,21 @@ export class ExecutorCallError extends Error {
30
30
  const executorProtoPath = resolve(dirname(fileURLToPath(import.meta.url)), '../../proto/executor.proto');
31
31
  // Set explicitly instead of inheriting @grpc/grpc-js's 4 MiB receive default. Keep in step with the
32
32
  // same constant in lens-remote-executor's src/main.ts: the Executor's server-side receive limit is
33
- // what actually bounds a write_file request, so both sides have to agree.
33
+ // what actually bounds a file-edit request, so both sides have to agree.
34
34
  const maxMessageBytes = 16 * 1024 * 1024;
35
35
  // Rejected in Core with a usable message rather than letting the transport fail with
36
36
  // RESOURCE_EXHAUSTED. Below maxMessageBytes so the rest of the request always fits.
37
37
  const contentLimitBytes = 8 * 1024 * 1024;
38
38
  export class ExecutorClient {
39
39
  config;
40
+ endpoint;
40
41
  id = 'executor';
41
42
  #client;
42
43
  #metadata = new grpc.Metadata();
43
- constructor(client, config, identity) {
44
+ #sharedPaths = new Set();
45
+ constructor(client, config, endpoint, identity) {
44
46
  this.config = config;
47
+ this.endpoint = endpoint;
45
48
  this.#client = client;
46
49
  if (identity !== undefined) {
47
50
  this.#metadata.set('x-lens-client-kind', 'native');
@@ -49,14 +52,25 @@ export class ExecutorClient {
49
52
  this.#metadata.set('x-lens-hardware-id', identity.hardwareId);
50
53
  }
51
54
  }
52
- static async create(root, runtime) {
53
- const config = runtime.current.config.executor;
55
+ static async create(root, runtime, endpoint = runtime.current.config.executor) {
56
+ const config = endpoint;
54
57
  const packageDefinition = protoLoader.loadSync(executorProtoPath, { keepCase: true, longs: Number, defaults: true, oneofs: true });
55
58
  const loaded = grpc.loadPackageDefinition(packageDefinition);
56
59
  const credentials = await channelCredentials(root, config);
57
60
  const clientIdentity = config.identityPath === undefined ? undefined : await loadIdentity(resolve(root, config.identityPath));
58
61
  const options = { 'grpc.max_receive_message_length': maxMessageBytes, 'grpc.max_send_message_length': maxMessageBytes };
59
- return new ExecutorClient(new loaded.lens.executor.v1.Executor(config.address, credentials, options), runtime, clientIdentity);
62
+ const adapter = new ExecutorClient(new loaded.lens.executor.v1.Executor(config.address, credentials, options), runtime, config, clientIdentity);
63
+ if (config.credentialPath !== undefined) {
64
+ if (config.tls?.enabled !== true && !/^(localhost|127\.0\.0\.1|\[::1\]):\d+$/u.test(config.address)) {
65
+ adapter.close();
66
+ throw new Error('Core Executor service credentials require TLS outside loopback.');
67
+ }
68
+ const token = (await readFile(resolve(root, config.credentialPath), 'utf8')).trim();
69
+ if (!/^[A-Za-z0-9_-]{43}$/u.test(token))
70
+ throw new Error('Invalid Core Executor service credential.');
71
+ adapter.#metadata.set('x-lens-core-credential', token);
72
+ }
73
+ return adapter;
60
74
  }
61
75
  close() {
62
76
  this.#client.close();
@@ -64,6 +78,23 @@ export class ExecutorClient {
64
78
  health(signal) {
65
79
  return this.call('health', {}, 5000, signal);
66
80
  }
81
+ async taskAudit(projectId, taskId, limit, cursor) {
82
+ const response = object(await this.call('queryTaskAudit', { project_id: projectId, task_id: taskId, limit, cursor: cursor ?? '' }, 30000));
83
+ if (!Array.isArray(response.events_json) || response.events_json.some(value => typeof value !== 'string')) {
84
+ fail('EXECUTOR_AUDIT_INVALID', 502, 'Executor returned an invalid audit page.');
85
+ }
86
+ let items;
87
+ try {
88
+ items = response.events_json.map((value) => jsonValue(JSON.parse(value)));
89
+ }
90
+ catch {
91
+ fail('EXECUTOR_AUDIT_INVALID', 502, 'Executor returned invalid audit events.');
92
+ }
93
+ return { items, next_cursor: typeof response.next_cursor === 'string' && response.next_cursor !== '' ? response.next_cursor : null,
94
+ coverage: { model_transport: 'not_queried', executor_access: 'executor_observed_requesting_declared_client_only',
95
+ exports: 'not_recorded', employee_identity: 'not_authenticated', completeness: 'partial', incomplete_tail: response.incomplete_tail === true },
96
+ integrity: string(response.integrity), ordering: string(response.ordering), scope: string(response.scope) };
97
+ }
67
98
  browse(path, signal) {
68
99
  return this.call('listAllowedParent', { path }, 30000, signal);
69
100
  }
@@ -73,8 +104,25 @@ export class ExecutorClient {
73
104
  open(workspacePaths, path, signal) {
74
105
  return this.call('openInFileManager', { workspace_paths: workspacePaths, path, full_access: false }, 30000, signal);
75
106
  }
76
- validatePaths(paths, signal, createIfMissing = false) {
77
- return this.call('validateWorkspacePaths', { workspace_paths: paths, create_if_missing: createIfMissing }, 30000, signal);
107
+ async validatePaths(paths, signal, createIfMissing = false) {
108
+ const response = await this.call('validateWorkspacePaths', { workspace_paths: paths, create_if_missing: createIfMissing }, 30000, signal);
109
+ const resolved = object(response).resolved_paths;
110
+ if (Array.isArray(resolved))
111
+ await this.verifySharedPaths(resolved.filter((path) => typeof path === 'string'), signal);
112
+ return response;
113
+ }
114
+ sharedWorkspace(path) { return this.#sharedPaths.has(path); }
115
+ async verifySharedPaths(paths, signal) {
116
+ if (this.endpoint.sharedFilesystem !== true)
117
+ return;
118
+ for (const root of paths) {
119
+ this.#sharedPaths.delete(root);
120
+ await verifySharedWorkspace(root, async (path, expected) => {
121
+ const response = object(await this.call('readFile', { workspace_paths: [root], path, full_access: false, max_bytes: 1024, max_lines: 2 }, 5000, signal));
122
+ return response.content === expected;
123
+ });
124
+ this.#sharedPaths.add(root);
125
+ }
78
126
  }
79
127
  // Drops the Executor's file history for Calls that Core has physically deleted, so revertable
80
128
  // state does not outlive the Call it belongs to. Called after the purge transaction commits --
@@ -90,24 +138,50 @@ export class ExecutorClient {
90
138
  }
91
139
  async execute(invocation) {
92
140
  const input = object(invocation.arguments);
141
+ const audit = new grpc.Metadata();
142
+ audit.set('x-lens-call-id', invocation.callId);
143
+ audit.set('x-lens-project-id', invocation.projectId);
144
+ audit.set('x-lens-workspace-id', invocation.workspaceId);
145
+ if (invocation.agentId !== null)
146
+ audit.set('x-lens-agent-id', invocation.agentId);
147
+ if (invocation.taskId !== undefined)
148
+ audit.set('x-lens-task-id', invocation.taskId);
149
+ const call = (method, request, timeout, signal) => this.call(method, request, timeout, signal, audit);
93
150
  const common = { workspace_paths: invocation.workspacePaths, full_access: invocation.fullAccess === true };
94
151
  const worker = this.config.current.config.worker;
95
- // write_file/edit_file carry the Workspace and Call identity so the Executor can record the
152
+ // edit_file/patch_file carry the Workspace and Call identity so the Executor can record the
96
153
  // change in its own file history for a later revert_call. Core never sees the file bytes or
97
154
  // their object ids -- see docs/architecture/harness-level3/09-file-snapshot-storage-decision.md.
98
155
  const change = { workspace_id: invocation.workspaceId, call_id: invocation.callId };
99
156
  if (invocation.capabilityId === 'read_file')
100
- return result(await this.call('readFile', { ...common, path: string(input.path) }, 30000, invocation.signal));
101
- if (invocation.capabilityId === 'write_file') {
102
- return result(await this.call('writeFile', { ...common, ...change, path: string(input.path), content: bounded(text(input.content), 'content') }, 30000, invocation.signal));
157
+ return result(await call('readFile', {
158
+ ...common, path: string(input.path), start_line: optionalNumber(input.start_line) ?? 1,
159
+ max_lines: optionalNumber(input.max_lines) ?? 2000, max_bytes: optionalNumber(input.max_bytes) ?? 65536,
160
+ offset: optionalNumber(input.offset) ?? 0
161
+ }, 30000, invocation.signal));
162
+ if (invocation.capabilityId === 'view_image') {
163
+ const image = object(await call('viewImage', { ...common, path: string(input.path) }, 30000, invocation.signal));
164
+ const bytes = Buffer.from(string(image.data), 'base64');
165
+ return { capture: 'complete', content: { path: string(input.path), media_type: string(image.media_type), size_bytes: bytes.length, ...(image.evidence == null ? {} : { evidence: jsonValue(image.evidence) }) },
166
+ artifacts: [{ name: string(input.path).split(/[\\/]/).pop() ?? 'image', mediaType: string(image.media_type), bytes }] };
103
167
  }
104
168
  if (invocation.capabilityId === 'edit_file') {
105
- return result(await this.call('editFile', { ...common, ...change, path: string(input.path), old_string: string(input.old_string), new_string: bounded(text(input.new_string), 'new_string'), replace_all: input.replace_all === true }, 30000, invocation.signal));
169
+ const edits = input.edits === undefined ? [] : editEntries(input.edits);
170
+ const content = input.content === undefined ? undefined : bounded(text(input.content), 'content');
171
+ const size = edits.reduce((bytes, edit) => bytes + Buffer.byteLength(edit.old_string) + Buffer.byteLength(edit.new_string), 0);
172
+ if (size > contentLimitBytes)
173
+ fail('FILE_TOO_LARGE', 413, 'Total edit input exceeds 8 MiB.');
174
+ return this.#fileChange('editFile', { ...common, ...change, path: string(input.path), operation: string(input.operation),
175
+ ...(content === undefined ? {} : { content }), edits }, invocation.signal, audit);
106
176
  }
177
+ if (invocation.capabilityId === 'patch_file')
178
+ return this.#fileChange('patchFile', {
179
+ ...common, ...change, path: string(input.path), patch: bounded(string(input.patch), 'patch')
180
+ }, invocation.signal, audit);
107
181
  if (invocation.capabilityId === 'glob')
108
- return result(await this.call('glob', { ...common, pattern: string(input.pattern), path: optionalString(input.path) ?? '.', include_hidden: input.include_hidden === true, max_results: optionalNumber(input.max_results) ?? 1000 }, 30000, invocation.signal));
182
+ return result(await call('glob', { ...common, pattern: string(input.pattern), path: optionalString(input.path) ?? '.', include_hidden: input.include_hidden === true, max_results: optionalNumber(input.max_results) ?? 1000 }, 30000, invocation.signal));
109
183
  if (invocation.capabilityId === 'grep')
110
- return result(await this.call('grep', {
184
+ return result(await call('grep', {
111
185
  ...common,
112
186
  pattern: string(input.pattern),
113
187
  path: optionalString(input.path) ?? '.',
@@ -120,30 +194,57 @@ export class ExecutorClient {
120
194
  }, 30000, invocation.signal));
121
195
  if (invocation.capabilityId === 'terminal_command_run') {
122
196
  const timeout = optionalNumber(input.timeout_ms) ?? worker.timeoutMs;
123
- return processResult(await this.call('runCommand', { ...common, command: string(input.command), args: strings(input.args), cwd: optionalString(input.cwd) ?? '.', env: stringRecord(input.env), timeout_ms: timeout, output_limit_bytes: optionalNumber(input.output_limit_bytes) ?? worker.outputLimitBytes }, timeout + 5000, invocation.signal));
197
+ return processResult(await call('runCommand', { ...common, command: string(input.command), args: strings(input.args), cwd: optionalString(input.cwd) ?? '.', env: stringRecord(input.env), timeout_ms: timeout, output_limit_bytes: optionalNumber(input.output_limit_bytes) ?? worker.outputLimitBytes }, timeout + 5000, invocation.signal));
124
198
  }
125
- if (invocation.capabilityId === 'git_run') {
126
- const timeout = optionalNumber(input.timeout_ms) ?? worker.timeoutMs;
127
- return processResult(await this.call('gitRun', { ...common, args: strings(input.args), cwd: optionalString(input.cwd) ?? '.', timeout_ms: timeout, output_limit_bytes: optionalNumber(input.output_limit_bytes) ?? worker.outputLimitBytes }, timeout + 5000, invocation.signal));
128
- }
129
- if (invocation.capabilityId === 'internet_search')
130
- return result(await this.call('internetSearch', { query: string(input.query), max_results: optionalNumber(input.max_results) ?? 5 }, 30000, invocation.signal));
131
- if (invocation.capabilityId === 'open_in_file_manager')
132
- return result(await this.call('openInFileManager', { ...common, path: optionalString(input.path) ?? '.' }, 30000, invocation.signal));
133
199
  fail('CAPABILITY_NOT_FOUND', 404, 'The Executor Capability does not exist.');
134
200
  }
135
- call(method, request, timeoutMs, signal) {
201
+ async #fileChange(method, request, signal, audit) {
202
+ try {
203
+ return { content: { structured_content: await this.call(method, request, 30000, signal, audit) }, capture: 'complete' };
204
+ }
205
+ catch (error) {
206
+ if (!(error instanceof ExecutorCallError))
207
+ throw error;
208
+ let details;
209
+ try {
210
+ details = JSON.parse(error.message);
211
+ }
212
+ catch {
213
+ throw error;
214
+ }
215
+ if (typeof details !== 'object' || details === null || !('code' in details) || typeof details.code !== 'string'
216
+ || !/^(?:FILE|EDIT|PATCH)_[A-Z_]+$/u.test(details.code))
217
+ throw error;
218
+ return { outcome: 'failed', errorCode: details.code, capture: 'complete', content: { error: jsonValue(details) } };
219
+ }
220
+ }
221
+ call(method, request, timeoutMs, signal, audit) {
222
+ const metadata = this.#metadata.clone();
223
+ if (audit !== undefined)
224
+ metadata.merge(audit);
136
225
  const invoke = this.#client[method];
137
226
  if (invoke === undefined)
138
227
  fail('EXECUTOR_PROTOCOL_ERROR', 503, `Executor RPC ${method} is unavailable.`);
139
228
  return new Promise((resolveResult, reject) => {
140
- const call = invoke.call(this.#client, request, this.#metadata, { deadline: Date.now() + timeoutMs }, (error, response) => {
141
- if (error !== null)
142
- reject(new ExecutorCallError(error.details || error.message, error.code));
143
- else
144
- resolveResult(jsonValue(response));
145
- });
146
- signal?.addEventListener('abort', () => call.cancel(), { once: true });
229
+ signal?.throwIfAborted();
230
+ let call;
231
+ const abort = () => { call?.cancel(); };
232
+ signal?.addEventListener('abort', abort, { once: true });
233
+ try {
234
+ call = invoke.call(this.#client, request, metadata, { deadline: Date.now() + timeoutMs }, (error, response) => {
235
+ signal?.removeEventListener('abort', abort);
236
+ if (error !== null)
237
+ reject(new ExecutorCallError(error.details || error.message, error.code));
238
+ else
239
+ resolveResult(jsonValue(response));
240
+ });
241
+ if (signal?.aborted === true)
242
+ call.cancel();
243
+ }
244
+ catch (error) {
245
+ signal?.removeEventListener('abort', abort);
246
+ throw error;
247
+ }
147
248
  });
148
249
  }
149
250
  }
@@ -152,56 +253,80 @@ export function executorCapabilities(adapter) {
152
253
  definition,
153
254
  adapter,
154
255
  manifest: capabilityManifest(definition),
155
- claims: (context) => claims(definition.id, object(context.arguments), context.workspacePaths)
256
+ claims: () => claims(definition.id)
156
257
  }));
157
258
  }
158
259
  const stringSchema = { type: 'string', minLength: 1 };
159
260
  const stringArray = { type: 'array', items: { type: 'string' } };
160
261
  const environmentSchema = { type: 'object', additionalProperties: { type: 'string' } };
161
262
  const positive = { type: 'integer', minimum: 1 };
162
- const outputLimits = { edit_file: 262144, internet_search: 262144, grep: 2097152, open_in_file_manager: 4096 };
263
+ const fileChangeSchema = {
264
+ type: 'object', additionalProperties: false,
265
+ properties: { path: stringSchema, changed: { type: 'boolean' }, units_applied: { type: 'integer', minimum: 0 }, bytes_written: { type: 'integer', minimum: 0 } },
266
+ required: ['path', 'changed', 'units_applied', 'bytes_written']
267
+ };
268
+ const outputLimits = { edit_file: 262144, patch_file: 262144, grep: 2097152 };
163
269
  const defaultAssignments = {
164
- read_file: 'all_new_agents', write_file: 'all_new_agents', edit_file: 'all_new_agents', glob: 'all_new_agents', grep: 'all_new_agents', open_in_file_manager: 'none'
270
+ read_file: 'all_new_agents', view_image: 'all_new_agents', edit_file: 'all_new_agents', patch_file: 'all_new_agents', glob: 'all_new_agents', grep: 'all_new_agents'
165
271
  };
166
272
  /**
167
273
  * The read-only file loop, loaded for every new Agent rather than discovered.
168
274
  *
169
275
  * Every Executor Capability used to be `deferred`, so a new Agent's loaded set held no way to see the
170
276
  * Workspace at all. Deferred loading is meant to keep rarely-used tool schemas out of the prompt, and
171
- * for `git_run` or `internet_search` that is right -- but an Agent cannot decide whether it needs to
277
+ * for `terminal_command_run` that is right -- but an Agent cannot decide whether it needs to
172
278
  * look at the files before it has looked at the files. Asked "does a PowerShell script run during the
173
279
  * VM build", one searched the catalogue for a VM-build capability, found none, and asked the user to
174
280
  * describe their setup -- while the answer sat in a Packer template two `glob` calls away. The failure
175
281
  * is structural, not a bad guess: discovery only works when the Agent knows what to look for, and the
176
282
  * first question about an unfamiliar Workspace is exactly when it does not.
177
283
  *
178
- * Three schemas is the whole cost. Writing, running commands and reaching the network stay deferred:
284
+ * Four schemas is the whole cost. Writing, running commands and reaching the network stay deferred:
179
285
  * those are decisions an Agent makes *after* reading, when it does know what it is looking for.
180
286
  */
181
- const eagerCapabilities = new Set(['read_file', 'glob', 'grep']);
287
+ const eagerCapabilities = new Set(['read_file', 'view_image', 'glob', 'grep']);
182
288
  const definitions = [
183
- definition('read_file', 'Read file', 'Read UTF-8 file content inside the Workspace.', 'read_only', { path: stringSchema }, ['path']),
184
- definition('write_file', 'Write file', 'Write UTF-8 file content inside the Workspace.', 'workspace_write', { path: stringSchema, content: { type: 'string' } }, ['path', 'content']),
185
- definition('edit_file', 'Edit file', 'Replace exact text inside a Workspace file.', 'workspace_write', { path: stringSchema, old_string: stringSchema, new_string: { type: 'string' }, replace_all: { type: 'boolean' } }, ['path', 'old_string', 'new_string']),
289
+ definition('read_file', 'Read file or directory', 'Read a UTF-8 text file or list a directory. Directory names are sorted, include hidden entries, and append / to subdirectories. Defaults to the first 2000 lines or entries, or 64 KiB. Use start_line (1-based), or pass next_offset as UTF-8 byte offset to continue. Do not combine a nonzero offset with start_line > 1. File content preserves line endings. kind identifies file/directory; eof indicates completion. Binary files are rejected.', 'read_only', {
290
+ path: stringSchema, start_line: { ...positive, maximum: Number.MAX_SAFE_INTEGER },
291
+ max_lines: { ...positive, maximum: 10000 }, max_bytes: { type: 'integer', minimum: 4, maximum: 262144 },
292
+ offset: { type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER }
293
+ }, ['path']),
294
+ definition('view_image', 'View image', 'View a PNG, JPEG, GIF or WebP image inside the Workspace. Returns image content to the model. Maximum file size: 5 MiB.', 'read_only', { path: stringSchema }, ['path']),
295
+ definition('edit_file', 'Edit file', 'Create a UTF-8 file, rewrite an existing file, or apply ordered literal replacements. create requires a missing target; edit/rewrite require an existing regular UTF-8 file. For create/rewrite provide content and omit edits; for edit provide edits and omit content. Empty content is allowed. Each old_string must match exactly once unless replace_all is true. All edits are validated before one atomic commit. At most 128 edits and 8 MiB of input/file content. Changes are recorded for revert_call.', 'workspace_write', {
296
+ path: stringSchema, operation: { type: 'string', enum: ['create', 'edit', 'rewrite'] }, content: { type: 'string' },
297
+ edits: { type: 'array', minItems: 1, maxItems: 128, items: { type: 'object', additionalProperties: false,
298
+ properties: { old_string: stringSchema, new_string: { type: 'string' }, replace_all: { type: 'boolean', default: false } }, required: ['old_string', 'new_string'] } }
299
+ }, ['path', 'operation'], fileChangeSchema),
300
+ definition('patch_file', 'Patch file', 'Apply contextual line-based update hunks to one existing UTF-8 file. Begin each hunk with @@ or @@ followed by an exact unique line anchor. Prefix unchanged lines with a space, additions with +, removals with -. Supply hunks only: path is separate. All hunks refer to the original file, must match uniquely, and cannot overlap. Optional *** End of File requires matching at the end. Pure additions require an empty file and one hunk. Preserve exact whitespace and unchanged line endings. At most 128 hunks and 8 MiB input/file content. Commit once and record for revert_call.', 'workspace_write', { path: stringSchema, patch: stringSchema }, ['path', 'patch'], fileChangeSchema),
186
301
  definition('glob', 'Glob files', 'Find Workspace files by wildcard pattern.', 'read_only', { pattern: stringSchema, path: stringSchema, include_hidden: { type: 'boolean' }, max_results: { ...positive, maximum: 10000 } }, ['pattern']),
187
- definition('grep', 'Search files', 'Find text or regular expression matches inside Workspace files.', 'read_only', { pattern: stringSchema, path: stringSchema, glob: stringSchema, literal: { type: 'boolean' }, case_sensitive: { type: 'boolean' }, include_hidden: { type: 'boolean' }, max_results: { ...positive, maximum: 10000 }, max_bytes_per_file: { ...positive, maximum: 10485760 } }, ['pattern']),
188
- definition('terminal_command_run', 'Run command', 'Run a command without a shell inside the Workspace.', 'full', { command: stringSchema, args: stringArray, cwd: stringSchema, env: environmentSchema, timeout_ms: positive, output_limit_bytes: positive }, ['command']),
189
- definition('git_run', 'Run Git', 'Run Git arguments inside the Workspace.', 'full', { args: stringArray, cwd: stringSchema, timeout_ms: positive, output_limit_bytes: positive }, ['args']),
190
- definition('internet_search', 'Search internet', 'Search public web pages.', 'external', { query: stringSchema, max_results: { ...positive, maximum: 20 } }, ['query']),
191
- definition('open_in_file_manager', 'Open in file manager', 'Open a Workspace path in the operating system file manager.', 'external', { path: stringSchema }, [])
302
+ definition('grep', 'Search files', 'Find text or regular expression matches inside Workspace files. Results include 1-based line numbers. truncated means additional matches exist or a file exceeded max_bytes_per_file; narrow path/glob or raise the limits to inspect more.', 'read_only', { pattern: stringSchema, path: stringSchema, glob: stringSchema, literal: { type: 'boolean' }, case_sensitive: { type: 'boolean' }, include_hidden: { type: 'boolean' }, max_results: { ...positive, maximum: 10000 }, max_bytes_per_file: { type: 'integer', minimum: 4, maximum: 10485760 } }, ['pattern']),
303
+ definition('terminal_command_run', 'Run command', 'Run a command without a shell inside the Workspace.', 'full', { command: stringSchema, args: stringArray, cwd: stringSchema, env: environmentSchema, timeout_ms: positive, output_limit_bytes: positive }, ['command'])
192
304
  ];
193
- function claims(capabilityId, input, paths) {
194
- if (capabilityId === 'internet_search')
195
- return [{ resource: 'network', mode: 'read' }];
196
- const path = optionalString(input.path) ?? optionalString(input.cwd) ?? '.';
197
- return [{ resource: pathResource(path, paths), mode: ['write_file', 'edit_file', 'terminal_command_run', 'git_run'].includes(capabilityId) ? 'write' : 'read' }];
305
+ function claims(capabilityId) {
306
+ // Core cannot canonicalize a remote filesystem. Cover all aliases and command
307
+ // side effects in this scheduler's execution domain; concurrent reads remain safe.
308
+ return [{ resource: '*', mode: ['edit_file', 'patch_file', 'terminal_command_run'].includes(capabilityId) ? 'write' : 'read' }];
198
309
  }
199
- // The resource name a file Capability claims for a path. Shared so revert_call names the same
200
- // resource its target write_file/edit_file Calls named -- claims only detect conflicts if both sides
201
- // spell the resource identically. Note this is a naive resolve, not a realpath: the vocabulary is
202
- // internally consistent rather than canonical, which is what conflict detection needs.
203
- export function pathResource(path, workspacePaths) {
204
- return isAbsolute(path) ? resolve(path) : resolve(workspacePaths[0] ?? '*', path);
310
+ export async function verifySharedWorkspace(root, read) {
311
+ let directory;
312
+ try {
313
+ const expected = randomBytes(32).toString('hex');
314
+ let path;
315
+ try {
316
+ directory = await mkdtemp(join(root, '.lens-shared-'));
317
+ path = join(directory, 'probe');
318
+ await writeFile(path, expected, { mode: 0o600 });
319
+ }
320
+ catch {
321
+ fail('SHARED_WORKSPACE_REQUIRED', 409, 'Core could not create a shared Workspace probe.');
322
+ }
323
+ if (!await read(path, expected))
324
+ fail('SHARED_WORKSPACE_REQUIRED', 409, 'Core and Executor do not share this Workspace.');
325
+ }
326
+ finally {
327
+ if (directory !== undefined)
328
+ await rm(directory, { recursive: true, force: true });
329
+ }
205
330
  }
206
331
  async function channelCredentials(root, config) {
207
332
  if (config.tls?.enabled !== true)
@@ -228,11 +353,20 @@ async function loadIdentity(path) {
228
353
  await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
229
354
  return value;
230
355
  }
231
- function definition(id, title, description, effect, properties, required) {
232
- return { id, title, description, effect, adapterId: 'executor', loadMode: eagerCapabilities.has(id) ? 'eager' : 'deferred', outputLimitBytes: outputLimits[id] ?? 1048576, defaultAssignment: defaultAssignments[id] ?? 'configured', inputSchema: { type: 'object', additionalProperties: false, properties, required } };
356
+ function definition(id, title, description, effect, properties, required, outputSchema) {
357
+ return { id, title, description, effect, adapterId: 'executor', loadMode: eagerCapabilities.has(id) ? 'eager' : 'deferred', outputLimitBytes: outputLimits[id] ?? 1048576, defaultAssignment: defaultAssignments[id] ?? 'configured', inputSchema: { type: 'object', additionalProperties: false, properties, required }, ...(outputSchema === undefined ? {} : { outputSchema }) };
358
+ }
359
+ function editEntries(value) {
360
+ if (!Array.isArray(value))
361
+ fail('INVALID_INPUT', 400, 'edits must be an array.');
362
+ return value.map((entry) => {
363
+ const edit = object(jsonValue(entry));
364
+ return { old_string: string(edit.old_string), new_string: text(edit.new_string), replace_all: edit.replace_all === true };
365
+ });
233
366
  }
234
367
  function result(content) {
235
- return { content };
368
+ const value = object(content);
369
+ return { content, capture: value.truncated === true ? 'partial' : value.truncated === false ? 'complete' : 'unknown' };
236
370
  }
237
371
  function bounded(value, field) {
238
372
  const bytes = Buffer.byteLength(value, 'utf8');
@@ -247,6 +381,7 @@ function processResult(content) {
247
381
  const failed = exitCode !== 0 || signal !== undefined;
248
382
  return {
249
383
  content,
384
+ capture: value.stdout_truncated === true || value.stderr_truncated === true ? 'partial' : value.stdout_truncated === false && value.stderr_truncated === false ? 'complete' : 'unknown',
250
385
  ...(failed ? { outcome: 'failed', errorCode: signal === 'TIMEOUT' ? 'WORKER_TIMEOUT' : signal === 'CANCELLED' ? 'WORKER_CANCELLED' : 'WORKER_EXIT_NONZERO' } : {})
251
386
  };
252
387
  }
@@ -4,7 +4,8 @@ import type { RegisteredCapability } from '../runtime/capabilities.js';
4
4
  export declare class LspPool {
5
5
  #private;
6
6
  readonly servers: NonNullable<HarnessConfig['lspServers']>;
7
- constructor(servers: NonNullable<HarnessConfig['lspServers']>);
7
+ readonly verifyWorkspace: (paths: readonly string[]) => Promise<void>;
8
+ constructor(servers: NonNullable<HarnessConfig['lspServers']>, verifyWorkspace?: (paths: readonly string[]) => Promise<void>);
8
9
  request(serverId: string, workspacePaths: readonly string[], sessionId: string, method: string, params: Json, signal: AbortSignal): Promise<Json>;
9
10
  projectPath(serverId: string, workspacePaths: readonly string[]): string;
10
11
  close(): Promise<void>;
@@ -6,24 +6,60 @@ import { LspProcess } from '../execution/lsp-process.js';
6
6
  import { capabilityManifest } from '../runtime/manifests.js';
7
7
  export class LspPool {
8
8
  servers;
9
+ verifyWorkspace;
9
10
  #clients = new Map();
10
- constructor(servers) {
11
+ #closed = false;
12
+ constructor(servers, verifyWorkspace = async () => { }) {
11
13
  this.servers = servers;
14
+ this.verifyWorkspace = verifyWorkspace;
12
15
  }
13
16
  async request(serverId, workspacePaths, sessionId, method, params, signal) {
17
+ signal.throwIfAborted();
18
+ if (this.#closed)
19
+ fail('LSP_SERVER_UNAVAILABLE', 503, 'The LSP pool is closed.');
20
+ await this.verifyWorkspace(workspacePaths);
21
+ signal.throwIfAborted();
14
22
  const projectPath = this.projectPath(serverId, workspacePaths);
15
23
  assertFileUris(workspacePaths, params);
16
- const selected = await this.#client(serverId, projectPath, sessionId);
24
+ const config = this.servers[serverId];
25
+ if (config === undefined)
26
+ fail('LSP_SERVER_UNAVAILABLE', 404, 'The LSP server is not configured.');
27
+ const sessionKey = key(serverId, projectPath, sessionId);
28
+ const restarted = this.#clients.get(sessionKey)?.process.closed ?? true;
29
+ const client = this.#client(serverId, projectPath, sessionKey);
30
+ client.active++;
31
+ if (client.timer !== undefined)
32
+ clearTimeout(client.timer);
33
+ const deadline = new AbortController();
34
+ const timeout = setTimeout(() => deadline.abort(Object.assign(new Error('The LSP request timed out.'), { code: 'LSP_TIMEOUT' })), config.requestTimeoutMs ?? 30000);
35
+ const combined = AbortSignal.any([signal, deadline.signal, client.controller.signal]);
17
36
  try {
37
+ const process = await abortable(client.promise, combined);
38
+ let response = null;
18
39
  if (isNotification(method))
19
- selected.process.notify(method, params);
20
- const response = isNotification(method) ? null : jsonValue(await selected.process.request(method, params, signal));
21
- return { method, response, notifications: selected.process.drainNotifications().map(jsonValue), restarted: selected.restarted };
40
+ await process.notify(method, params, false, combined);
41
+ else
42
+ response = jsonValue(await process.request(method, params, combined));
43
+ return { method, response, notifications: process.drainNotifications().map(jsonValue), restarted };
22
44
  }
23
- catch (error) {
24
- if (selected.process.closed)
25
- this.#clients.delete(key(serverId, projectPath, sessionId));
26
- throw error;
45
+ finally {
46
+ clearTimeout(timeout);
47
+ client.active--;
48
+ if (!client.ready && client.active === 0) {
49
+ client.controller.abort(new Error('No callers remain for LSP initialization.'));
50
+ await client.process.close();
51
+ if (this.#clients.get(sessionKey) === client)
52
+ this.#clients.delete(sessionKey);
53
+ }
54
+ else if (client.active === 0 && this.#clients.get(sessionKey) === client) {
55
+ client.timer = setTimeout(() => {
56
+ if (client.active === 0 && this.#clients.get(sessionKey) === client) {
57
+ this.#clients.delete(sessionKey);
58
+ void client.process.close();
59
+ }
60
+ }, config.idleTimeoutMs ?? 300000);
61
+ client.timer.unref();
62
+ }
27
63
  }
28
64
  }
29
65
  projectPath(serverId, workspacePaths) {
@@ -37,44 +73,65 @@ export class LspPool {
37
73
  return project;
38
74
  }
39
75
  async close() {
40
- await Promise.allSettled([...this.#clients.values()].map(async (client) => (await client).close()));
76
+ this.#closed = true;
77
+ const clients = [...this.#clients.values()];
41
78
  this.#clients.clear();
79
+ for (const client of clients) {
80
+ if (client.timer !== undefined)
81
+ clearTimeout(client.timer);
82
+ client.controller.abort(new Error('The LSP pool closed.'));
83
+ }
84
+ await Promise.allSettled(clients.map(client => client.process.close()));
42
85
  }
43
- async #client(serverId, workspacePath, sessionId) {
44
- const sessionKey = key(serverId, workspacePath, sessionId);
45
- const existing = this.#clients.get(sessionKey);
46
- if (existing !== undefined) {
47
- const process = await existing;
48
- if (!process.closed)
49
- return { process, restarted: false };
50
- this.#clients.delete(sessionKey);
86
+ #client(serverId, workspacePath, sessionKey) {
87
+ for (const [key, client] of this.#clients) {
88
+ if (!client.process.closed)
89
+ continue;
90
+ if (client.timer !== undefined)
91
+ clearTimeout(client.timer);
92
+ this.#clients.delete(key);
93
+ void client.process.close();
51
94
  }
95
+ const existing = this.#clients.get(sessionKey);
96
+ if (existing !== undefined)
97
+ return existing;
52
98
  const config = this.servers[serverId];
53
99
  if (config === undefined)
54
- fail('LSP_SERVER_UNAVAILABLE', 404, `LSP server ${serverId} is not configured.`);
55
- const created = Promise.resolve().then(async () => {
56
- const process = new LspProcess(config.command, config.args ?? [], workspacePath, config.maxFrameBytes ?? 4194304, config.maxBufferBytes ?? 8388608);
57
- await process.request('initialize', {
58
- processId: process.pid ?? null,
59
- rootUri: pathToFileURL(workspacePath).href,
60
- capabilities: {},
61
- initializationOptions: config.initializationOptions ?? null
62
- }, AbortSignal.timeout(10000));
63
- process.markInitialized();
64
- process.notify('initialized', {});
65
- return process;
66
- });
67
- this.#clients.set(sessionKey, created);
68
- try {
69
- return { process: await created, restarted: true };
70
- }
71
- catch (error) {
72
- this.#clients.delete(sessionKey);
73
- void created.then((process) => process.close(), () => undefined);
74
- throw error;
75
- }
100
+ fail('LSP_SERVER_UNAVAILABLE', 404, 'The LSP server is not configured.');
101
+ if ([...this.#clients.values()].filter(client => client.serverId === serverId).length >= (config.maxSessions ?? 16))
102
+ fail('LSP_SESSION_LIMIT', 429, 'The LSP session limit has been reached.');
103
+ const process = new LspProcess(config.command, config.args ?? [], workspacePath, config.maxFrameBytes ?? 4194304, config.maxBufferBytes ?? 8388608, config.requestTimeoutMs ?? 30000);
104
+ const client = { serverId, process, controller: new AbortController(), promise: Promise.resolve(process), active: 0, ready: false, timer: undefined };
105
+ this.#clients.set(sessionKey, client);
106
+ client.promise = (async () => {
107
+ try {
108
+ await process.request('initialize', { processId: null, rootUri: pathToFileURL(workspacePath).href, capabilities: {}, initializationOptions: config.initializationOptions ?? null }, client.controller.signal);
109
+ client.controller.signal.throwIfAborted();
110
+ process.markInitialized();
111
+ await process.notify('initialized', {});
112
+ client.ready = true;
113
+ return process;
114
+ }
115
+ catch (error) {
116
+ await process.close();
117
+ if (this.#clients.get(sessionKey) === client)
118
+ this.#clients.delete(sessionKey);
119
+ throw error;
120
+ }
121
+ })();
122
+ void client.promise.catch(() => undefined);
123
+ return client;
76
124
  }
77
125
  }
126
+ function abortable(promise, signal) {
127
+ if (signal.aborted)
128
+ return Promise.reject(signal.reason instanceof Error ? signal.reason : new Error('LSP request cancelled.'));
129
+ return new Promise((resolve, reject) => {
130
+ const abort = () => { signal.removeEventListener('abort', abort); reject(signal.reason instanceof Error ? signal.reason : new Error('LSP request cancelled.')); };
131
+ signal.addEventListener('abort', abort, { once: true });
132
+ void promise.then(value => { signal.removeEventListener('abort', abort); resolve(value); }, (error) => { signal.removeEventListener('abort', abort); reject(error instanceof Error ? error : new Error(String(error))); });
133
+ });
134
+ }
78
135
  export class LspAdapter {
79
136
  pool;
80
137
  id = 'lsp';
@@ -123,7 +180,7 @@ export function lspCapabilities(config, pool) {
123
180
  adapter,
124
181
  manifest: capabilityManifest(definition, ['read_only', 'workspace_write']),
125
182
  effect: (input) => writeMethods.has(string(object(input).method)) ? 'workspace_write' : 'read_only',
126
- claims: (context) => [{ resource: context.workspacePaths[0] ?? '*', mode: writeMethods.has(string(object(context.arguments).method)) ? 'write' : 'read' }]
183
+ claims: (context) => [{ resource: '*', mode: writeMethods.has(string(object(context.arguments).method)) ? 'write' : 'read' }]
127
184
  }];
128
185
  }
129
186
  const allowedMethods = new Set([