@lasso-ai/cli 2.0.1 → 2.1.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.
@@ -1,9 +1,13 @@
1
1
  /**
2
2
  * Lasso Runtime Client
3
3
  *
4
- * Client for the new Lasso Agentic Runtime
5
- * Uses SSE for real-time progress events
4
+ * Client for the hosted-model agentic runtime. The server orchestrates the
5
+ * agent loop and calls the model, but never touches the local filesystem:
6
+ * workspace tools arrive as `tool_call` events and are executed HERE, in the
7
+ * CLI, against the real project directory. The CLI also owns the
8
+ * WorkspaceGuard (snapshot → diff → rollback → review → approval).
6
9
  */
10
+ import { type ToolCallRequest } from "../runtime/index.js";
7
11
  export type RuntimeProvider = "anthropic" | "openai" | "google" | "nvidia" | "ollama" | "claude_code" | "opencode" | "codex_cli" | "cursor";
8
12
  export interface RuntimeSessionInput {
9
13
  userPrompt: string;
@@ -178,6 +182,11 @@ export type RuntimeEvent = {
178
182
  success: boolean;
179
183
  };
180
184
  timestamp: number;
185
+ } | {
186
+ type: "tool_call";
187
+ sessionId: string;
188
+ data: ToolCallRequest;
189
+ timestamp: number;
181
190
  } | {
182
191
  type: "observation";
183
192
  sessionId: string;
@@ -252,19 +261,38 @@ export type RuntimeEventHandler = (event: RuntimeEvent) => void | Promise<void>;
252
261
  export declare class RuntimeClient {
253
262
  private serverUrl;
254
263
  private apiKey?;
264
+ /**
265
+ * Review payloads computed locally from the WorkspaceGuard diff, keyed by
266
+ * sessionId. Merged into getSession() so the bridge's review flow sees the
267
+ * authoritative pendingChanges without the server ever touching the FS.
268
+ */
269
+ private localReviewCache;
255
270
  constructor(serverUrl: string, apiKey?: string);
256
271
  /**
257
272
  * Create a new runtime session
258
273
  */
259
274
  createSession(input: RuntimeSessionInput): Promise<RuntimeSession>;
260
275
  /**
261
- * Get a session by ID
276
+ * Get a session by ID. Locally computed review changes (WorkspaceGuard
277
+ * diff from the last run) are merged into metadata.pendingChanges.
262
278
  */
263
279
  getSession(sessionId: string): Promise<RuntimeSession>;
264
280
  /**
265
- * Run a session with SSE event streaming
281
+ * Run a session with SSE event streaming.
282
+ *
283
+ * Hosted-model flow (server orchestrates, CLI executes):
284
+ * 1. Capture the WorkspaceGuard BEFORE the run (CLI owns the snapshot).
285
+ * 2. Stream events from the server. `tool_call` events are executed
286
+ * locally (ActionExecutor / VerificationEngine) and the result is
287
+ * posted back to the server so the model loop can continue.
288
+ * 3. On agent_completed: diff the workspace, roll it back, and cache the
289
+ * changes for the review panel. Only user approval re-applies them.
266
290
  */
267
291
  runSession(sessionId: string, projectPath: string, onEvent: RuntimeEventHandler, signal?: AbortSignal): Promise<void>;
292
+ /** Execute a tool_call request locally against the real project. */
293
+ private executeToolCallLocally;
294
+ /** Post a tool result back to the server (resolves its pending request). */
295
+ private postToolResult;
268
296
  /**
269
297
  * Cancel a running session
270
298
  */
@@ -280,8 +308,9 @@ export declare class RuntimeClient {
280
308
  /**
281
309
  * Verify the workspace for a session (after reviewed changes were applied).
282
310
  *
283
- * Returns whether verification passed; on failure the session keeps the
284
- * verification errors so a corrective re-run can fix them.
311
+ * Hosted sessions verify LOCALLY (the server cannot run typecheck/build
312
+ * against a filesystem it does not have). The results are also persisted
313
+ * on the server session so a corrective re-run has them in context.
285
314
  */
286
315
  verifySession(sessionId: string, projectPath: string): Promise<SessionVerificationResult>;
287
316
  /**
@@ -2,14 +2,28 @@
2
2
  /**
3
3
  * Lasso Runtime Client
4
4
  *
5
- * Client for the new Lasso Agentic Runtime
6
- * Uses SSE for real-time progress events
5
+ * Client for the hosted-model agentic runtime. The server orchestrates the
6
+ * agent loop and calls the model, but never touches the local filesystem:
7
+ * workspace tools arrive as `tool_call` events and are executed HERE, in the
8
+ * CLI, against the real project directory. The CLI also owns the
9
+ * WorkspaceGuard (snapshot → diff → rollback → review → approval).
7
10
  */
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
8
14
  Object.defineProperty(exports, "__esModule", { value: true });
9
15
  exports.RuntimeClient = void 0;
16
+ const node_crypto_1 = __importDefault(require("node:crypto"));
17
+ const index_js_1 = require("../runtime/index.js");
10
18
  class RuntimeClient {
11
19
  serverUrl;
12
20
  apiKey;
21
+ /**
22
+ * Review payloads computed locally from the WorkspaceGuard diff, keyed by
23
+ * sessionId. Merged into getSession() so the bridge's review flow sees the
24
+ * authoritative pendingChanges without the server ever touching the FS.
25
+ */
26
+ localReviewCache = new Map();
13
27
  constructor(serverUrl, apiKey) {
14
28
  this.serverUrl = serverUrl;
15
29
  this.apiKey = apiKey;
@@ -33,7 +47,8 @@ class RuntimeClient {
33
47
  return data;
34
48
  }
35
49
  /**
36
- * Get a session by ID
50
+ * Get a session by ID. Locally computed review changes (WorkspaceGuard
51
+ * diff from the last run) are merged into metadata.pendingChanges.
37
52
  */
38
53
  async getSession(sessionId) {
39
54
  const response = await fetch(`${this.serverUrl}/api/v1/agent-runtime/sessions/${sessionId}`, {
@@ -45,12 +60,67 @@ class RuntimeClient {
45
60
  throw new Error(`Failed to get session: ${response.statusText}`);
46
61
  }
47
62
  const data = await response.json();
63
+ const local = this.localReviewCache.get(sessionId);
64
+ if (local && local.length > 0) {
65
+ data.metadata = { ...(data.metadata || {}), pendingChanges: local };
66
+ }
48
67
  return data;
49
68
  }
50
69
  /**
51
- * Run a session with SSE event streaming
70
+ * Run a session with SSE event streaming.
71
+ *
72
+ * Hosted-model flow (server orchestrates, CLI executes):
73
+ * 1. Capture the WorkspaceGuard BEFORE the run (CLI owns the snapshot).
74
+ * 2. Stream events from the server. `tool_call` events are executed
75
+ * locally (ActionExecutor / VerificationEngine) and the result is
76
+ * posted back to the server so the model loop can continue.
77
+ * 3. On agent_completed: diff the workspace, roll it back, and cache the
78
+ * changes for the review panel. Only user approval re-applies them.
52
79
  */
53
80
  async runSession(sessionId, projectPath, onEvent, signal) {
81
+ // CLI-side snapshot: nothing the agent does is left on disk before review.
82
+ let guard = null;
83
+ try {
84
+ guard = await index_js_1.WorkspaceGuard.capture(projectPath);
85
+ }
86
+ catch (error) {
87
+ console.error("WorkspaceGuard capture failed:", error);
88
+ }
89
+ const executor = new index_js_1.ActionExecutor(projectPath);
90
+ const verifier = new index_js_1.VerificationEngine(projectPath);
91
+ const finalizeGuard = async () => {
92
+ if (!guard)
93
+ return;
94
+ try {
95
+ const changes = guard.diff();
96
+ const applyable = changes.filter((c) => !c.binary);
97
+ const pending = applyable.map((change) => ({
98
+ filePath: change.filePath,
99
+ oldString: change.operation === "created" ? "" : change.oldContent ?? "",
100
+ newString: change.operation === "deleted" ? "" : change.newContent ?? "",
101
+ operation: change.operation,
102
+ }));
103
+ this.localReviewCache.set(sessionId, pending);
104
+ }
105
+ catch (error) {
106
+ console.error("WorkspaceGuard diff failed:", error);
107
+ }
108
+ finally {
109
+ // Always restore BEFORE the review flow runs — it captures originals
110
+ // from disk for the undo snapshot, so the workspace must be back to
111
+ // its pre-run state by the time agent_completed is delivered.
112
+ try {
113
+ await guard.restore();
114
+ }
115
+ catch (error) {
116
+ console.error("WorkspaceGuard restore failed:", error);
117
+ }
118
+ }
119
+ };
120
+ const handleToolCall = async (request) => {
121
+ const result = await this.executeToolCallLocally(request, executor, verifier);
122
+ await this.postToolResult(sessionId, result);
123
+ };
54
124
  const response = await fetch(`${this.serverUrl}/api/v1/agent-runtime/sessions/${sessionId}/run`, {
55
125
  method: "POST",
56
126
  headers: {
@@ -83,6 +153,15 @@ class RuntimeClient {
83
153
  continue;
84
154
  try {
85
155
  const event = JSON.parse(data);
156
+ if (event.type === "tool_call") {
157
+ // Execute locally and answer the server — never forwarded raw.
158
+ await handleToolCall(event.data);
159
+ continue;
160
+ }
161
+ if (event.type === "agent_completed") {
162
+ // Diff + rollback BEFORE the review flow reads the session.
163
+ await finalizeGuard();
164
+ }
86
165
  await onEvent(event);
87
166
  }
88
167
  catch (error) {
@@ -93,6 +172,71 @@ class RuntimeClient {
93
172
  }
94
173
  finally {
95
174
  reader.releaseLock();
175
+ // Safety net: if the stream died mid-run, still restore the workspace.
176
+ if (guard) {
177
+ guard.restore().catch(() => { });
178
+ }
179
+ }
180
+ }
181
+ /** Execute a tool_call request locally against the real project. */
182
+ async executeToolCallLocally(request, executor, verifier) {
183
+ try {
184
+ if (request.kind === "verify") {
185
+ const results = await verifier.verifyAll(request.verify?.phase || "verify");
186
+ return {
187
+ callId: request.callId,
188
+ success: verifier.allPassed(results),
189
+ observations: verifier.getObservations(results),
190
+ errors: verifier.getErrors(results),
191
+ };
192
+ }
193
+ if (!request.action) {
194
+ return { callId: request.callId, success: false, error: "tool_call missing action" };
195
+ }
196
+ const result = await executor.execute(request.action);
197
+ return {
198
+ callId: request.callId,
199
+ success: result.success,
200
+ output: result.output,
201
+ error: result.error,
202
+ observations: result.observation
203
+ ? [{
204
+ id: node_crypto_1.default.randomUUID(),
205
+ type: result.observation.type,
206
+ summary: result.observation.summary,
207
+ data: result.observation.data,
208
+ timestamp: Date.now(),
209
+ phase: "execute",
210
+ actionId: request.action.id,
211
+ }]
212
+ : undefined,
213
+ };
214
+ }
215
+ catch (error) {
216
+ return {
217
+ callId: request.callId,
218
+ success: false,
219
+ error: error instanceof Error ? error.message : String(error),
220
+ };
221
+ }
222
+ }
223
+ /** Post a tool result back to the server (resolves its pending request). */
224
+ async postToolResult(sessionId, result) {
225
+ try {
226
+ const response = await fetch(`${this.serverUrl}/api/v1/agent-runtime/sessions/${sessionId}/tool-results`, {
227
+ method: "POST",
228
+ headers: {
229
+ "Content-Type": "application/json",
230
+ ...(this.apiKey && { "Authorization": `Bearer ${this.apiKey}` }),
231
+ },
232
+ body: JSON.stringify(result),
233
+ });
234
+ if (!response.ok) {
235
+ console.error(`Failed to post tool result: ${response.statusText}`);
236
+ }
237
+ }
238
+ catch (error) {
239
+ console.error("Failed to post tool result:", error);
96
240
  }
97
241
  }
98
242
  /**
@@ -128,22 +272,36 @@ class RuntimeClient {
128
272
  /**
129
273
  * Verify the workspace for a session (after reviewed changes were applied).
130
274
  *
131
- * Returns whether verification passed; on failure the session keeps the
132
- * verification errors so a corrective re-run can fix them.
275
+ * Hosted sessions verify LOCALLY (the server cannot run typecheck/build
276
+ * against a filesystem it does not have). The results are also persisted
277
+ * on the server session so a corrective re-run has them in context.
133
278
  */
134
279
  async verifySession(sessionId, projectPath) {
135
- const response = await fetch(`${this.serverUrl}/api/v1/agent-runtime/sessions/${sessionId}/verify`, {
136
- method: "POST",
137
- headers: {
138
- "Content-Type": "application/json",
139
- ...(this.apiKey && { "Authorization": `Bearer ${this.apiKey}` }),
140
- },
141
- body: JSON.stringify({ projectPath }),
142
- });
143
- if (!response.ok) {
144
- throw new Error(`Failed to verify session: ${response.statusText}`);
280
+ const engine = new index_js_1.VerificationEngine(projectPath);
281
+ const results = await engine.verifyAll("verify");
282
+ const passed = engine.allPassed(results);
283
+ const errors = engine.getErrors(results);
284
+ const observations = engine.getObservations(results);
285
+ // Persist on the server session (best-effort — corrective context).
286
+ try {
287
+ await fetch(`${this.serverUrl}/api/v1/agent-runtime/sessions/${sessionId}/verify`, {
288
+ method: "POST",
289
+ headers: {
290
+ "Content-Type": "application/json",
291
+ ...(this.apiKey && { "Authorization": `Bearer ${this.apiKey}` }),
292
+ },
293
+ body: JSON.stringify({ projectPath, localResults: { passed, errors, observations } }),
294
+ });
145
295
  }
146
- return (await response.json());
296
+ catch {
297
+ // Non-fatal: the local result is still returned to the caller.
298
+ }
299
+ return {
300
+ passed,
301
+ errors,
302
+ observations,
303
+ requiresFix: !passed,
304
+ };
147
305
  }
148
306
  /**
149
307
  * List available backends from the server (hosted providers + local agents).
@@ -0,0 +1,76 @@
1
+ import type { AgentAction } from "./agent-runtime";
2
+ /**
3
+ * Action Executor - Executes validated model actions on the LOCAL machine.
4
+ *
5
+ * Used by the CLI to fulfil `tool_call` requests from the hosted-model
6
+ * orchestrator. Every path is validated to stay inside the project directory
7
+ * (workspace boundary enforcement happens client-side, never on the server).
8
+ */
9
+ export interface ActionExecutionResult {
10
+ success: boolean;
11
+ actionId: string;
12
+ output?: any;
13
+ error?: string;
14
+ observation?: {
15
+ type: string;
16
+ summary: string;
17
+ data?: any;
18
+ };
19
+ }
20
+ export declare class ActionExecutor {
21
+ private projectPath;
22
+ constructor(projectPath: string);
23
+ /**
24
+ * Execute a single action
25
+ */
26
+ execute(action: AgentAction): Promise<ActionExecutionResult>;
27
+ /**
28
+ * Execute multiple actions (stop on first failure)
29
+ */
30
+ executeBatch(actions: AgentAction[]): Promise<ActionExecutionResult[]>;
31
+ /**
32
+ * Read file action
33
+ */
34
+ private executeReadFile;
35
+ /**
36
+ * Edit file action
37
+ */
38
+ private executeEditFile;
39
+ /**
40
+ * Create file action
41
+ */
42
+ private executeCreateFile;
43
+ /**
44
+ * Delete file action
45
+ */
46
+ private executeDeleteFile;
47
+ /**
48
+ * Run command action
49
+ */
50
+ private executeRunCommand;
51
+ /**
52
+ * Search code action
53
+ */
54
+ private executeSearchCode;
55
+ /**
56
+ * Inspect project action
57
+ */
58
+ private executeInspectProject;
59
+ /**
60
+ * Browser check action (placeholder for now)
61
+ */
62
+ private executeBrowserCheck;
63
+ /**
64
+ * Resolve a path relative to the project and enforce the workspace
65
+ * boundary: every resolved path must stay inside projectPath.
66
+ */
67
+ private resolvePath;
68
+ /**
69
+ * List all project files recursively
70
+ */
71
+ private listProjectFiles;
72
+ /**
73
+ * Read package.json if it exists
74
+ */
75
+ private readPackageJson;
76
+ }
@@ -0,0 +1,420 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ActionExecutor = void 0;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const node_child_process_1 = require("node:child_process");
10
+ const node_util_1 = require("node:util");
11
+ const exec = (0, node_util_1.promisify)(node_child_process_1.exec);
12
+ class ActionExecutor {
13
+ projectPath;
14
+ constructor(projectPath) {
15
+ this.projectPath = projectPath;
16
+ }
17
+ /**
18
+ * Execute a single action
19
+ */
20
+ async execute(action) {
21
+ const { id, type } = action;
22
+ try {
23
+ switch (type) {
24
+ case "read_file":
25
+ return await this.executeReadFile(action);
26
+ case "edit_file":
27
+ return await this.executeEditFile(action);
28
+ case "create_file":
29
+ return await this.executeCreateFile(action);
30
+ case "delete_file":
31
+ return await this.executeDeleteFile(action);
32
+ case "run_command":
33
+ return await this.executeRunCommand(action);
34
+ case "search_code":
35
+ return await this.executeSearchCode(action);
36
+ case "inspect_project":
37
+ return await this.executeInspectProject(action);
38
+ case "browser_check":
39
+ return await this.executeBrowserCheck(action);
40
+ default:
41
+ return {
42
+ success: false,
43
+ actionId: id,
44
+ error: `Unknown action type: ${type}`,
45
+ };
46
+ }
47
+ }
48
+ catch (error) {
49
+ return {
50
+ success: false,
51
+ actionId: id,
52
+ error: error instanceof Error ? error.message : String(error),
53
+ };
54
+ }
55
+ }
56
+ /**
57
+ * Execute multiple actions (stop on first failure)
58
+ */
59
+ async executeBatch(actions) {
60
+ const results = [];
61
+ for (const action of actions) {
62
+ const result = await this.execute(action);
63
+ results.push(result);
64
+ // Stop if an action fails
65
+ if (!result.success) {
66
+ break;
67
+ }
68
+ }
69
+ return results;
70
+ }
71
+ /**
72
+ * Read file action
73
+ */
74
+ async executeReadFile(action) {
75
+ const { id, data } = action;
76
+ const filePath = data.path;
77
+ const fullPath = this.resolvePath(filePath);
78
+ if (!node_fs_1.default.existsSync(fullPath)) {
79
+ return {
80
+ success: false,
81
+ actionId: id,
82
+ error: `File not found: ${filePath}`,
83
+ };
84
+ }
85
+ const content = node_fs_1.default.readFileSync(fullPath, "utf-8");
86
+ return {
87
+ success: true,
88
+ actionId: id,
89
+ output: { content, path: filePath },
90
+ observation: {
91
+ type: "file",
92
+ summary: `Read file: ${filePath}`,
93
+ data: { path: filePath, size: content.length },
94
+ },
95
+ };
96
+ }
97
+ /**
98
+ * Edit file action
99
+ */
100
+ async executeEditFile(action) {
101
+ const { id, data } = action;
102
+ const filePath = data.path;
103
+ const oldString = data.oldString;
104
+ const newString = data.newString;
105
+ const fullPath = this.resolvePath(filePath);
106
+ if (!node_fs_1.default.existsSync(fullPath)) {
107
+ return {
108
+ success: false,
109
+ actionId: id,
110
+ error: `File not found: ${filePath}`,
111
+ };
112
+ }
113
+ const content = node_fs_1.default.readFileSync(fullPath, "utf-8");
114
+ if (oldString && !content.includes(oldString)) {
115
+ return {
116
+ success: false,
117
+ actionId: id,
118
+ error: `oldString not found in file: ${filePath}`,
119
+ };
120
+ }
121
+ const newContent = oldString ? content.replace(oldString, newString) : newString;
122
+ // No local backup: the WorkspaceGuard snapshot taken before the run is
123
+ // the authoritative rollback mechanism (and .lasso-backup files would
124
+ // pollute the review diff).
125
+ node_fs_1.default.writeFileSync(fullPath, newContent);
126
+ return {
127
+ success: true,
128
+ actionId: id,
129
+ output: { path: filePath },
130
+ observation: {
131
+ type: "file",
132
+ summary: `Edited file: ${filePath}`,
133
+ data: { path: filePath },
134
+ },
135
+ };
136
+ }
137
+ /**
138
+ * Create file action
139
+ */
140
+ async executeCreateFile(action) {
141
+ const { id, data } = action;
142
+ const filePath = data.path;
143
+ const content = data.content;
144
+ const fullPath = this.resolvePath(filePath);
145
+ if (node_fs_1.default.existsSync(fullPath)) {
146
+ return {
147
+ success: false,
148
+ actionId: id,
149
+ error: `File already exists: ${filePath}`,
150
+ };
151
+ }
152
+ // Ensure directory exists
153
+ const dir = node_path_1.default.dirname(fullPath);
154
+ if (!node_fs_1.default.existsSync(dir)) {
155
+ node_fs_1.default.mkdirSync(dir, { recursive: true });
156
+ }
157
+ node_fs_1.default.writeFileSync(fullPath, content || "");
158
+ return {
159
+ success: true,
160
+ actionId: id,
161
+ output: { path: filePath },
162
+ observation: {
163
+ type: "file",
164
+ summary: `Created file: ${filePath}`,
165
+ data: { path: filePath },
166
+ },
167
+ };
168
+ }
169
+ /**
170
+ * Delete file action
171
+ */
172
+ async executeDeleteFile(action) {
173
+ const { id, data } = action;
174
+ const filePath = data.path;
175
+ const fullPath = this.resolvePath(filePath);
176
+ if (!node_fs_1.default.existsSync(fullPath)) {
177
+ return {
178
+ success: false,
179
+ actionId: id,
180
+ error: `File not found: ${filePath}`,
181
+ };
182
+ }
183
+ // No local backup: the WorkspaceGuard snapshot is the authoritative
184
+ // rollback mechanism.
185
+ node_fs_1.default.unlinkSync(fullPath);
186
+ return {
187
+ success: true,
188
+ actionId: id,
189
+ output: { path: filePath },
190
+ observation: {
191
+ type: "file",
192
+ summary: `Deleted file: ${filePath}`,
193
+ data: { path: filePath },
194
+ },
195
+ };
196
+ }
197
+ /**
198
+ * Run command action
199
+ */
200
+ async executeRunCommand(action) {
201
+ const { id, data } = action;
202
+ const command = data.command;
203
+ const cwd = data.cwd;
204
+ const workingDir = cwd ? this.resolvePath(cwd) : this.projectPath;
205
+ if (!node_fs_1.default.existsSync(workingDir)) {
206
+ return {
207
+ success: false,
208
+ actionId: id,
209
+ error: `Working directory not found: ${workingDir}`,
210
+ };
211
+ }
212
+ // Safety check: prevent dangerous commands
213
+ const dangerousPatterns = [
214
+ /\brm\s+-rf\b/,
215
+ /\bdd\s+if=/,
216
+ />\s*\/dev\/null/,
217
+ />\s*\/dev\/sd/,
218
+ ];
219
+ for (const pattern of dangerousPatterns) {
220
+ if (pattern.test(command)) {
221
+ return {
222
+ success: false,
223
+ actionId: id,
224
+ error: `Command rejected for safety: ${command}`,
225
+ };
226
+ }
227
+ }
228
+ try {
229
+ const { stdout, stderr } = await exec(command, {
230
+ cwd: workingDir,
231
+ timeout: 120000, // 2 minute timeout
232
+ });
233
+ return {
234
+ success: true,
235
+ actionId: id,
236
+ output: { stdout, stderr },
237
+ observation: {
238
+ type: "command",
239
+ summary: `Ran command: ${command}`,
240
+ data: { command, exitCode: 0 },
241
+ },
242
+ };
243
+ }
244
+ catch (error) {
245
+ return {
246
+ success: false,
247
+ actionId: id,
248
+ error: error.message,
249
+ output: { stdout: error.stdout, stderr: error.stderr },
250
+ observation: {
251
+ type: "command",
252
+ summary: `Command failed: ${command}`,
253
+ data: { command, exitCode: error.code || 1 },
254
+ },
255
+ };
256
+ }
257
+ }
258
+ /**
259
+ * Search code action
260
+ */
261
+ async executeSearchCode(action) {
262
+ const { id, data } = action;
263
+ const pattern = data.pattern;
264
+ const filePattern = data.filePattern;
265
+ const maxResults = data.maxResults || 50;
266
+ // Use grep to search
267
+ const grepPattern = filePattern ? `--include=${filePattern}` : "";
268
+ const command = `grep -r -n "${pattern}" ${grepPattern} . 2>/dev/null | head -n ${maxResults}`;
269
+ try {
270
+ const { stdout } = await exec(command, {
271
+ cwd: this.projectPath,
272
+ timeout: 30000, // 30 second timeout
273
+ });
274
+ const results = stdout
275
+ .trim()
276
+ .split("\n")
277
+ .filter(Boolean)
278
+ .map((line) => {
279
+ const [filePath, lineNum, ...content] = line.split(":");
280
+ return {
281
+ file: filePath,
282
+ line: parseInt(lineNum, 10),
283
+ content: content.join(":").trim(),
284
+ };
285
+ });
286
+ return {
287
+ success: true,
288
+ actionId: id,
289
+ output: { results },
290
+ observation: {
291
+ type: "search",
292
+ summary: `Found ${results.length} matches for: ${pattern}`,
293
+ data: { pattern, count: results.length },
294
+ },
295
+ };
296
+ }
297
+ catch (error) {
298
+ // grep returns non-zero if no matches found
299
+ if (error.code === 1 && !error.stdout) {
300
+ return {
301
+ success: true,
302
+ actionId: id,
303
+ output: { results: [] },
304
+ observation: {
305
+ type: "search",
306
+ summary: `No matches found for: ${pattern}`,
307
+ data: { pattern, count: 0 },
308
+ },
309
+ };
310
+ }
311
+ return {
312
+ success: false,
313
+ actionId: id,
314
+ error: error.message,
315
+ };
316
+ }
317
+ }
318
+ /**
319
+ * Inspect project action
320
+ */
321
+ async executeInspectProject(action) {
322
+ const { id } = action;
323
+ const files = this.listProjectFiles(this.projectPath);
324
+ const packageJson = this.readPackageJson();
325
+ return {
326
+ success: true,
327
+ actionId: id,
328
+ output: { files, packageJson },
329
+ observation: {
330
+ type: "file",
331
+ summary: `Inspected project: ${files.length} files`,
332
+ data: { fileCount: files.length },
333
+ },
334
+ };
335
+ }
336
+ /**
337
+ * Browser check action (placeholder for now)
338
+ */
339
+ async executeBrowserCheck(action) {
340
+ const { id, data } = action;
341
+ const url = data.url;
342
+ const check = data.check;
343
+ // TODO: Implement actual browser checking via Puppeteer or similar
344
+ return {
345
+ success: true,
346
+ actionId: id,
347
+ output: { url, check, result: "pending" },
348
+ observation: {
349
+ type: "browser",
350
+ summary: `Browser check pending: ${url}`,
351
+ data: { url, check },
352
+ },
353
+ };
354
+ }
355
+ /**
356
+ * Resolve a path relative to the project and enforce the workspace
357
+ * boundary: every resolved path must stay inside projectPath.
358
+ */
359
+ resolvePath(filePath) {
360
+ const resolved = node_path_1.default.isAbsolute(filePath)
361
+ ? node_path_1.default.resolve(filePath)
362
+ : node_path_1.default.resolve(this.projectPath, filePath);
363
+ const root = node_path_1.default.resolve(this.projectPath);
364
+ if (resolved !== root && !resolved.startsWith(root + node_path_1.default.sep)) {
365
+ throw new Error(`Path escapes workspace: ${filePath}`);
366
+ }
367
+ return resolved;
368
+ }
369
+ /**
370
+ * List all project files recursively
371
+ */
372
+ listProjectFiles(dir, maxDepth = 10) {
373
+ const files = [];
374
+ let depth = 0;
375
+ const traverse = (currentDir) => {
376
+ if (depth > maxDepth)
377
+ return;
378
+ const entries = node_fs_1.default.readdirSync(currentDir, { withFileTypes: true });
379
+ for (const entry of entries) {
380
+ const fullPath = node_path_1.default.join(currentDir, entry.name);
381
+ // Skip common ignored directories
382
+ if (entry.name === "node_modules" ||
383
+ entry.name === ".git" ||
384
+ entry.name === "dist" ||
385
+ entry.name === "build" ||
386
+ entry.name === ".next" ||
387
+ entry.name === ".lasso") {
388
+ continue;
389
+ }
390
+ if (entry.isDirectory()) {
391
+ depth++;
392
+ traverse(fullPath);
393
+ depth--;
394
+ }
395
+ else {
396
+ files.push(node_path_1.default.relative(this.projectPath, fullPath));
397
+ }
398
+ }
399
+ };
400
+ traverse(dir);
401
+ return files;
402
+ }
403
+ /**
404
+ * Read package.json if it exists
405
+ */
406
+ readPackageJson() {
407
+ const packageJsonPath = node_path_1.default.join(this.projectPath, "package.json");
408
+ if (!node_fs_1.default.existsSync(packageJsonPath)) {
409
+ return null;
410
+ }
411
+ try {
412
+ const content = node_fs_1.default.readFileSync(packageJsonPath, "utf-8");
413
+ return JSON.parse(content);
414
+ }
415
+ catch {
416
+ return null;
417
+ }
418
+ }
419
+ }
420
+ exports.ActionExecutor = ActionExecutor;
@@ -199,7 +199,7 @@ export interface LassoModelResponse {
199
199
  actions: AgentAction[];
200
200
  expectedOutcome?: string;
201
201
  }
202
- export type AgentEventType = "agent_started" | "agent_reasoning" | "agent_completed" | "agent_failed" | "agent_cancelled" | "phase_changed" | "tool_started" | "tool_completed" | "observation" | "plan_created" | "file_changed" | "verification_started" | "verification_completed" | "error" | "model_changed" | "waiting_for_user";
202
+ export type AgentEventType = "agent_started" | "agent_reasoning" | "agent_completed" | "agent_failed" | "agent_cancelled" | "phase_changed" | "tool_started" | "tool_completed" | "tool_call" | "observation" | "plan_created" | "file_changed" | "verification_started" | "verification_completed" | "error" | "model_changed" | "waiting_for_user";
203
203
  export interface AgentEvent {
204
204
  type: AgentEventType;
205
205
  sessionId: string;
@@ -237,6 +237,22 @@ export interface ToolCompletedEvent extends AgentEvent {
237
237
  duration?: number;
238
238
  };
239
239
  }
240
+ /**
241
+ * Emitted by the server when the hosted model requests a workspace tool.
242
+ * The local CLI executes the tool and posts the result back to
243
+ * `POST /sessions/:sessionId/tool-results` (see tool-protocol.ts).
244
+ */
245
+ export interface ToolCallEvent extends AgentEvent {
246
+ type: "tool_call";
247
+ data: {
248
+ callId: string;
249
+ kind: "action" | "verify";
250
+ action?: AgentAction;
251
+ verify?: {
252
+ phase?: string;
253
+ };
254
+ };
255
+ }
240
256
  export interface ObservationEvent extends AgentEvent {
241
257
  type: "observation";
242
258
  data: {
@@ -11,6 +11,8 @@ export * from "./agent-state-machine";
11
11
  export * from "./event-mapper";
12
12
  export * from "./review";
13
13
  export * from "./local-session-runner";
14
+ export * from "./tool-protocol";
15
+ export { ActionExecutor, ActionExecutionResult } from "./action-executor";
14
16
  export * from "./adapters/factory";
15
17
  export { LocalAgentAdapter, EventSink, newRunState } from "./adapters/local-agent-adapter";
16
18
  export type { LocalAgentConfig, CommandSpec, AgentRunState } from "./adapters/local-agent-adapter";
@@ -19,7 +19,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
19
19
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
20
20
  };
21
21
  Object.defineProperty(exports, "__esModule", { value: true });
22
- exports.CursorAdapter = exports.CodexCLIAdapter = exports.OpenCodeAdapter = exports.ClaudeCodeAdapter = exports.newRunState = exports.EventSink = exports.LocalAgentAdapter = void 0;
22
+ exports.CursorAdapter = exports.CodexCLIAdapter = exports.OpenCodeAdapter = exports.ClaudeCodeAdapter = exports.newRunState = exports.EventSink = exports.LocalAgentAdapter = exports.ActionExecutor = void 0;
23
23
  __exportStar(require("./agent-runtime"), exports);
24
24
  __exportStar(require("./backend-interface"), exports);
25
25
  __exportStar(require("./workspace-guard"), exports);
@@ -28,6 +28,9 @@ __exportStar(require("./agent-state-machine"), exports);
28
28
  __exportStar(require("./event-mapper"), exports);
29
29
  __exportStar(require("./review"), exports);
30
30
  __exportStar(require("./local-session-runner"), exports);
31
+ __exportStar(require("./tool-protocol"), exports);
32
+ var action_executor_1 = require("./action-executor");
33
+ Object.defineProperty(exports, "ActionExecutor", { enumerable: true, get: function () { return action_executor_1.ActionExecutor; } });
31
34
  __exportStar(require("./adapters/factory"), exports);
32
35
  var local_agent_adapter_1 = require("./adapters/local-agent-adapter");
33
36
  Object.defineProperty(exports, "LocalAgentAdapter", { enumerable: true, get: function () { return local_agent_adapter_1.LocalAgentAdapter; } });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Tool-call protocol for hosted-model agent runs.
3
+ *
4
+ * The server orchestrates the agent loop and calls the hosted model, but it
5
+ * never touches the user's filesystem. When the model requests a workspace
6
+ * action (read/edit/run/verify/...), the server emits a `tool_call` event over
7
+ * the runtime event channel (SSE). The local CLI executes the tool against
8
+ * the real project directory and posts the result back to
9
+ * `POST /sessions/:sessionId/tool-results`, where the orchestrator feeds it
10
+ * back into the model loop.
11
+ */
12
+ import type { AgentAction, AgentError, Observation } from "./agent-runtime";
13
+ /** A tool execution request from the server to the local CLI. */
14
+ export interface ToolCallRequest {
15
+ /** Unique id correlating the request with its result. */
16
+ callId: string;
17
+ sessionId: string;
18
+ /** `action` = workspace tool; `verify` = run the verification engine. */
19
+ kind: "action" | "verify";
20
+ /** Present when kind === "action". */
21
+ action?: AgentAction;
22
+ /** Present when kind === "verify". */
23
+ verify?: {
24
+ phase?: string;
25
+ };
26
+ }
27
+ /** The CLI's response to a tool_call, posted back to the server. */
28
+ export interface ToolCallResultPayload {
29
+ callId: string;
30
+ success: boolean;
31
+ output?: unknown;
32
+ error?: string;
33
+ /** Observations to feed back into the model's context. */
34
+ observations?: Observation[];
35
+ /** Errors to record on the session (e.g. verification failures). */
36
+ errors?: AgentError[];
37
+ }
38
+ /** Default per-tool timeouts (ms). run_command is long-running by nature. */
39
+ export declare const TOOL_CALL_TIMEOUT_MS: Record<string, number>;
40
+ export declare function toolCallTimeoutMs(tool: string): number;
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TOOL_CALL_TIMEOUT_MS = void 0;
4
+ exports.toolCallTimeoutMs = toolCallTimeoutMs;
5
+ /** Default per-tool timeouts (ms). run_command is long-running by nature. */
6
+ exports.TOOL_CALL_TIMEOUT_MS = {
7
+ run_command: 180_000,
8
+ verify: 300_000,
9
+ default: 120_000,
10
+ };
11
+ function toolCallTimeoutMs(tool) {
12
+ return exports.TOOL_CALL_TIMEOUT_MS[tool] ?? exports.TOOL_CALL_TIMEOUT_MS.default;
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lasso-ai/cli",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "Select any part of your running app, describe a change, and let AI edit the real source code.",
5
5
  "type": "commonjs",
6
6
  "main": "dist/cli/index.js",