minovative-mind-cli 2.0.0 → 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.
package/README.md CHANGED
@@ -116,6 +116,14 @@ Hot-swap during a session using `/models`:
116
116
 
117
117
  ---
118
118
 
119
+ ## 🌐 Multi-Workspace & Cross-Repo Support
120
+
121
+ Minovative Mind CLI doesn't restrict you to a single repository. You can link multiple external workspaces to your current session and the AI will seamlessly operate across all of them simultaneously.
122
+
123
+ By prefixing file paths with `@alias/` (e.g. `@backend/src/api.ts` and `@frontend/src/App.tsx`), the Context Agent, Thread Agents, and Semantic Search tools can investigate, refactor, and coordinate changes across your entire tech stack in a single prompt. The terminal highlights cross-workspace actions with a blue `[alias]` visual tag. Use the "Edit Workspace" menu option to configure your linked projects.
124
+
125
+ ---
126
+
119
127
  ## 🌐 Supported Languages
120
128
 
121
129
  Minovative Mind CLI fundamentally supports **ALL programming languages** for chat, code generation, planning, and execution, as it relies on Gemini's vast training data.
@@ -30,6 +30,7 @@ Inside the chat session, you can use the following commands in the slash menu:
30
30
  /auto-approve - Toggle automatic approval of tool/command runs
31
31
  /sub-agents - Toggle the MMAAK Engine for parallel investigation and execution
32
32
  /semantic-search - Toggle local vector index capabilities
33
+ /workspaces - Manage external workspaces for cross-project development
33
34
  /stats - View current session statistics and configuration
34
35
  /commit - Commit current workspace changes to Git
35
36
  /revert - Revert the last file modification made by the agent
@@ -76,6 +77,8 @@ Chat Controls:
76
77
  if (idToken) {
77
78
  updateWorkspaceStatus(idToken, workspaceRoot).catch(() => { });
78
79
  }
80
+ const { workspaceRegistry } = await import('../services/workspaceRegistry.js');
81
+ workspaceRegistry.init();
79
82
  await startAgentLoop(workspaceRoot, this.config.version);
80
83
  }
81
84
  }
@@ -233,14 +233,33 @@ export async function handleSlashCommand(command, context) {
233
233
  }
234
234
  let targetTimestamp = lastChangeSet ? lastChangeSet.timestamp : null;
235
235
  if (revertMenu === 'view_history') {
236
+ const { workspaceRegistry } = await import('../workspaceRegistry.js');
236
237
  const historyOptions = history
237
238
  .slice()
238
239
  .reverse()
239
- .map((cs, i) => ({
240
- value: cs.timestamp,
241
- label: `[${i === 0 ? 'Latest' : `-${i}`}] ${truncate(cs.description, 50)} (${new Date(cs.timestamp).toLocaleTimeString()})${cs.status === 'partial' ? ' [Partial]' : ''}`,
242
- hint: `Reverts this and all ${i} changes after it`,
243
- }));
240
+ .map((cs, i) => {
241
+ const externalWorkspaces = new Set();
242
+ for (const change of cs.changes) {
243
+ if (change.filePath.startsWith('@')) {
244
+ const slashIndex = change.filePath.indexOf('/');
245
+ const aliasStr = slashIndex === -1 ? change.filePath.substring(1) : change.filePath.substring(1, slashIndex);
246
+ const ws = workspaceRegistry.get(aliasStr);
247
+ if (ws) {
248
+ const rootName = path.basename(ws.absolutePath);
249
+ externalWorkspaces.add(rootName);
250
+ }
251
+ else {
252
+ externalWorkspaces.add(`@${aliasStr}`);
253
+ }
254
+ }
255
+ }
256
+ const tags = externalWorkspaces.size > 0 ? pc.blue(` [${Array.from(externalWorkspaces).join(', ')}]`) : '';
257
+ return {
258
+ value: cs.timestamp,
259
+ label: `[${i === 0 ? 'Latest' : `-${i}`}] ${truncate(cs.description, 50)}${tags} (${new Date(cs.timestamp).toLocaleTimeString()})${cs.status === 'partial' ? ' [Partial]' : ''}`,
260
+ hint: `Reverts this and all ${i} changes after it`,
261
+ };
262
+ });
244
263
  const selectedHistory = await p['select']({
245
264
  message: 'Select the point in history to revert back to:',
246
265
  options: [...historyOptions, { value: -1, label: 'Cancel' }],
@@ -255,13 +274,14 @@ export async function handleSlashCommand(command, context) {
255
274
  try {
256
275
  const changesToRevert = changeLogger.popUntil(targetTimestamp);
257
276
  const flatChanges = changesToRevert.flatMap((cs) => cs.changes);
277
+ const { resolveAndValidateMultiWorkspacePath } = await import('../../utils/pathSecurity.js');
258
278
  for (const change of flatChanges) {
259
- const absPath = path.resolve(workspaceRoot, change.filePath);
279
+ const { absolutePath } = resolveAndValidateMultiWorkspacePath(workspaceRoot, change.filePath);
260
280
  if (change.action === 'create') {
261
- await fs.rm(absPath, { force: true });
281
+ await fs.rm(absolutePath, { force: true });
262
282
  }
263
283
  else if (change.originalContent !== null) {
264
- await fs.writeFile(absPath, change.originalContent, 'utf-8');
284
+ await fs.writeFile(absolutePath, change.originalContent, 'utf-8');
265
285
  }
266
286
  }
267
287
  spinner.stop('Reverted successfully.');
@@ -308,13 +328,41 @@ export async function handleSlashCommand(command, context) {
308
328
  console.log(pc.dim('\nType your coding request below. Type "exit" or "quit" to leave.\n'));
309
329
  return { shouldContinue: true };
310
330
  }
331
+ const { workspaceRegistry } = await import('../workspaceRegistry.js');
311
332
  const sessionOptions = sessions
312
333
  .slice()
313
334
  .reverse()
314
- .map((s) => ({
315
- value: s.id,
316
- label: `${truncate(s.title, 50)} (${new Date(s.timestamp).toLocaleString()})`,
317
- }));
335
+ .map((s) => {
336
+ const externalWorkspaces = new Set();
337
+ if (s.history) {
338
+ for (const item of s.history) {
339
+ if (item.role === 'user' || !item.parts)
340
+ continue;
341
+ for (const part of item.parts) {
342
+ if (part.functionCall && part.functionCall.args && part.functionCall.args.filePath) {
343
+ const p = part.functionCall.args.filePath;
344
+ if (typeof p === 'string' && p.startsWith('@')) {
345
+ const slashIndex = p.indexOf('/');
346
+ const aliasStr = slashIndex === -1 ? p.substring(1) : p.substring(1, slashIndex);
347
+ const ws = workspaceRegistry.get(aliasStr);
348
+ if (ws) {
349
+ const rootName = path.basename(ws.absolutePath);
350
+ externalWorkspaces.add(rootName);
351
+ }
352
+ else {
353
+ externalWorkspaces.add(`@${aliasStr}`);
354
+ }
355
+ }
356
+ }
357
+ }
358
+ }
359
+ }
360
+ const tags = externalWorkspaces.size > 0 ? pc.blue(` [${Array.from(externalWorkspaces).join(', ')}]`) : '';
361
+ return {
362
+ value: s.id,
363
+ label: `${truncate(s.title, 50)}${tags} (${new Date(s.timestamp).toLocaleString()})`,
364
+ };
365
+ });
318
366
  if (chatsMenu === 'resume') {
319
367
  const selectedSessionId = await p['select']({
320
368
  message: 'Select a session to resume:',
@@ -386,6 +434,159 @@ export async function handleSlashCommand(command, context) {
386
434
  }
387
435
  return { shouldContinue: true };
388
436
  }
437
+ if (lowerCommand === '/workspaces') {
438
+ const { workspaceRegistry } = await import('../workspaceRegistry.js');
439
+ while (true) {
440
+ const allRoots = workspaceRegistry.getAllRoots(workspaceRoot);
441
+ const options = [];
442
+ options.push({ value: 'add', label: 'Add Workspace' });
443
+ const externalRoots = allRoots.filter(r => r.alias);
444
+ if (externalRoots.length > 0) {
445
+ options.push({ value: 'edit', label: 'Edit Workspace' });
446
+ options.push({ value: 'remove', label: 'Remove Workspace' });
447
+ options.push({ value: 'list', label: 'List Workspaces' });
448
+ }
449
+ options.push({ value: 'cancel', label: 'Exit Menu' });
450
+ const action = await p['select']({
451
+ message: 'Manage External Workspaces',
452
+ options,
453
+ });
454
+ if (p.isCancel(action) || action === 'cancel') {
455
+ break;
456
+ }
457
+ if (action === 'add') {
458
+ const aliasStr = await p['text']({
459
+ message: 'Enter a short alias (e.g. backend, ui):',
460
+ validate: (val) => {
461
+ if (!val)
462
+ return 'Alias is required';
463
+ if (!/^[a-zA-Z0-9_-]+$/.test(val))
464
+ return 'Only letters, numbers, hyphens, and underscores';
465
+ if (workspaceRegistry.getAllRoots(workspaceRoot).some(r => r.alias === val))
466
+ return 'Alias already in use';
467
+ }
468
+ });
469
+ if (p.isCancel(aliasStr))
470
+ continue;
471
+ const rootPathStr = await p['text']({
472
+ message: 'Enter the absolute path to the workspace root (e.g. /Users/name/Projects/app):',
473
+ validate: (val) => {
474
+ if (!val)
475
+ return 'Path is required';
476
+ }
477
+ });
478
+ if (p.isCancel(rootPathStr))
479
+ continue;
480
+ let cleanRootPathStr = rootPathStr.trim();
481
+ if ((cleanRootPathStr.startsWith("'") && cleanRootPathStr.endsWith("'")) ||
482
+ (cleanRootPathStr.startsWith('"') && cleanRootPathStr.endsWith('"'))) {
483
+ cleanRootPathStr = cleanRootPathStr.slice(1, -1);
484
+ }
485
+ try {
486
+ const stats = await fs.stat(cleanRootPathStr);
487
+ if (!stats.isDirectory()) {
488
+ p.log.error('Path is not a directory.');
489
+ continue;
490
+ }
491
+ workspaceRegistry.register(aliasStr, cleanRootPathStr);
492
+ p.log.success(`Added @${aliasStr} -> ${cleanRootPathStr}`);
493
+ }
494
+ catch (e) {
495
+ p.log.error(`Invalid path or directory does not exist: ${cleanRootPathStr}`);
496
+ }
497
+ }
498
+ else if (action === 'edit') {
499
+ const editOptions = externalRoots.map(r => ({
500
+ value: r.alias,
501
+ label: `@${r.alias} -> ${r.root}`
502
+ }));
503
+ editOptions.push({ value: 'cancel', label: 'Cancel' });
504
+ const aliasToEdit = await p['select']({
505
+ message: 'Select workspace to edit:',
506
+ options: editOptions
507
+ });
508
+ if (p.isCancel(aliasToEdit) || aliasToEdit === 'cancel')
509
+ continue;
510
+ const ws = workspaceRegistry.get(aliasToEdit);
511
+ if (!ws)
512
+ continue;
513
+ const newAliasStr = await p['text']({
514
+ message: `Enter new alias (current: ${ws.alias}):`,
515
+ initialValue: ws.alias,
516
+ validate: (val) => {
517
+ if (!val)
518
+ return 'Alias is required';
519
+ if (!/^[a-zA-Z0-9_-]+$/.test(val))
520
+ return 'Only letters, numbers, hyphens, and underscores';
521
+ if (val !== ws.alias && workspaceRegistry.getAllRoots(workspaceRoot).some(r => r.alias === val))
522
+ return 'Alias already in use';
523
+ }
524
+ });
525
+ if (p.isCancel(newAliasStr))
526
+ continue;
527
+ const newRootPathStr = await p['text']({
528
+ message: `Enter absolute path to workspace root (e.g. /Users/name/Projects/app):`,
529
+ initialValue: ws.absolutePath,
530
+ validate: (val) => {
531
+ if (!val)
532
+ return 'Path is required';
533
+ }
534
+ });
535
+ if (p.isCancel(newRootPathStr))
536
+ continue;
537
+ let cleanNewRootPathStr = newRootPathStr.trim();
538
+ if ((cleanNewRootPathStr.startsWith("'") && cleanNewRootPathStr.endsWith("'")) ||
539
+ (cleanNewRootPathStr.startsWith('"') && cleanNewRootPathStr.endsWith('"'))) {
540
+ cleanNewRootPathStr = cleanNewRootPathStr.slice(1, -1);
541
+ }
542
+ try {
543
+ const stats = await fs.stat(cleanNewRootPathStr);
544
+ if (!stats.isDirectory()) {
545
+ p.log.error('Path is not a directory.');
546
+ continue;
547
+ }
548
+ // Remove old alias first to avoid duplicate alias error, or to clean up
549
+ workspaceRegistry.unregister(ws.alias);
550
+ workspaceRegistry.register(newAliasStr, cleanNewRootPathStr);
551
+ p.log.success(`Updated @${newAliasStr} -> ${cleanNewRootPathStr}`);
552
+ }
553
+ catch (e) {
554
+ // If register failed, try to rollback
555
+ p.log.error(`Failed to update workspace: ${e instanceof Error ? e.message : String(e)}`);
556
+ try {
557
+ workspaceRegistry.register(ws.alias, ws.absolutePath);
558
+ }
559
+ catch { }
560
+ }
561
+ }
562
+ else if (action === 'remove') {
563
+ const removeOptions = externalRoots.map(r => ({
564
+ value: r.alias,
565
+ label: `@${r.alias} -> ${r.root}`
566
+ }));
567
+ removeOptions.push({ value: 'cancel', label: 'Cancel' });
568
+ const aliasToRemove = await p['select']({
569
+ message: 'Select workspace to remove:',
570
+ options: removeOptions
571
+ });
572
+ if (p.isCancel(aliasToRemove) || aliasToRemove === 'cancel')
573
+ continue;
574
+ workspaceRegistry.unregister(aliasToRemove);
575
+ p.log.success(`Removed @${aliasToRemove}`);
576
+ }
577
+ else if (action === 'list') {
578
+ for (const { alias, root } of allRoots) {
579
+ if (alias) {
580
+ p.log.step(`${pc.blue(`@${alias}`)} -> ${pc.dim(root)}`);
581
+ }
582
+ else {
583
+ p.log.step(`${pc.cyan('(primary)')} -> ${pc.dim(root)}`);
584
+ }
585
+ }
586
+ }
587
+ }
588
+ return { shouldContinue: true };
589
+ }
389
590
  if (lowerCommand === '/commit') {
390
591
  const commitSpinner = p.spinner();
391
592
  commitSpinner.start('Staging changes and analyzing diff...');
@@ -4,7 +4,8 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import { SchemaType } from '@google/generative-ai';
7
- import { resolveAndValidatePath } from '../utils/pathSecurity.js';
7
+ import { resolveAndValidatePath, resolveAndValidateMultiWorkspacePath } from '../utils/pathSecurity.js';
8
+ import { workspaceRegistry } from './workspaceRegistry.js';
8
9
  import { changeLogger } from './changeLogger.js';
9
10
  import { findBestMatch, applyMatch } from '../utils/fuzzyMatch.js';
10
11
  import { validateSyntax } from '../utils/syntaxValidator.js';
@@ -36,13 +37,13 @@ export function getToolDeclarations() {
36
37
  export const toolDeclarations = [
37
38
  {
38
39
  name: 'read_file',
39
- description: 'Read the contents of a file at the given path relative to the workspace root. Supports text files and native parsing of .pdf files (including math and diagrams). Use startLine and endLine to read specific chunks of massive files to avoid context limits.',
40
+ description: 'Read the contents of a file at the given path relative to the workspace root. Supports text files and native parsing of .pdf files (including math and diagrams). Use startLine and endLine to read specific chunks of massive files to avoid context limits. For files in external workspaces, prefix the path with @alias/ (e.g., @backend/src/routes.ts).',
40
41
  parameters: {
41
42
  type: SchemaType.OBJECT,
42
43
  properties: {
43
44
  filePath: {
44
45
  type: SchemaType.STRING,
45
- description: 'Relative path to the file from the workspace root.',
46
+ description: 'Relative path to the file from the workspace root, or @alias/path for external workspaces.',
46
47
  },
47
48
  startLine: {
48
49
  type: SchemaType.NUMBER,
@@ -63,13 +64,13 @@ export const toolDeclarations = [
63
64
  },
64
65
  {
65
66
  name: 'write_file',
66
- description: 'Create a new file or completely overwrite an existing file with the provided content. Use modify_file for targeted edits instead.',
67
+ description: 'Create a new file or completely overwrite an existing file with the provided content. Use modify_file for targeted edits instead. For files in external workspaces, prefix the path with @alias/ (e.g., @backend/src/routes.ts).',
67
68
  parameters: {
68
69
  type: SchemaType.OBJECT,
69
70
  properties: {
70
71
  filePath: {
71
72
  type: SchemaType.STRING,
72
- description: 'Relative path to the file from the workspace root.',
73
+ description: 'Relative path to the file from the workspace root, or @alias/path for external workspaces.',
73
74
  },
74
75
  content: {
75
76
  type: SchemaType.STRING,
@@ -81,13 +82,13 @@ export const toolDeclarations = [
81
82
  },
82
83
  {
83
84
  name: 'delete_file',
84
- description: 'Deletes a file from the filesystem. Use this instead of running an rm command.',
85
+ description: 'Deletes a file from the filesystem. Use this instead of running an rm command. For files in external workspaces, prefix the path with @alias/ (e.g., @backend/src/routes.ts).',
85
86
  parameters: {
86
87
  type: SchemaType.OBJECT,
87
88
  properties: {
88
89
  filePath: {
89
90
  type: SchemaType.STRING,
90
- description: 'Relative path to the file to delete.',
91
+ description: 'Relative path to the file to delete, or @alias/path for external workspaces.',
91
92
  },
92
93
  },
93
94
  required: ['filePath'],
@@ -95,17 +96,17 @@ export const toolDeclarations = [
95
96
  },
96
97
  {
97
98
  name: 'rename_file',
98
- description: 'Moves or renames a file. Use this instead of running an mv command.',
99
+ description: 'Moves or renames a file. Use this instead of running an mv command. For files in external workspaces, prefix the path with @alias/ (e.g., @backend/src/routes.ts).',
99
100
  parameters: {
100
101
  type: SchemaType.OBJECT,
101
102
  properties: {
102
103
  sourcePath: {
103
104
  type: SchemaType.STRING,
104
- description: 'Relative path to the file to move/rename.',
105
+ description: 'Relative path to the file to move/rename, or @alias/path for external workspaces.',
105
106
  },
106
107
  targetPath: {
107
108
  type: SchemaType.STRING,
108
- description: 'New relative path for the file.',
109
+ description: 'New relative path for the file, or @alias/path for external workspaces.',
109
110
  },
110
111
  },
111
112
  required: ['sourcePath', 'targetPath'],
@@ -113,13 +114,13 @@ export const toolDeclarations = [
113
114
  },
114
115
  {
115
116
  name: 'modify_file',
116
- description: 'Perform one or multiple targeted search-and-replace edits in a single file. CRITICAL REQUIREMENT: You MUST use read_file or grep_search to fetch the exact current file content BEFORE using this tool. Do NOT guess or hallucinate the searchContent without reading the exact lines first, or the edit will fail. The search strings must match the current file exactly (including whitespace). This is preferred over write_file for editing existing files.',
117
+ description: 'Perform one or multiple targeted search-and-replace edits in a single file. CRITICAL REQUIREMENT: You MUST use read_file or grep_search to fetch the exact current file content BEFORE using this tool. Do NOT guess or hallucinate the searchContent without reading the exact lines first, or the edit will fail. The search strings must match the current file exactly (including whitespace). This is preferred over write_file for editing existing files. For files in external workspaces, prefix the path with @alias/ (e.g., @backend/src/routes.ts).',
117
118
  parameters: {
118
119
  type: SchemaType.OBJECT,
119
120
  properties: {
120
121
  filePath: {
121
122
  type: SchemaType.STRING,
122
- description: 'Relative path to the file from the workspace root.',
123
+ description: 'Relative path to the file from the workspace root, or @alias/path for external workspaces.',
123
124
  },
124
125
  edits: {
125
126
  type: SchemaType.ARRAY,
@@ -145,13 +146,13 @@ export const toolDeclarations = [
145
146
  },
146
147
  {
147
148
  name: 'list_directory',
148
- description: 'List files and subdirectories in a directory relative to the workspace root. Returns a recursive tree structure.',
149
+ description: 'List files and subdirectories in a directory relative to the workspace root. Returns a recursive tree structure. For directories in external workspaces, prefix the path with @alias/ (e.g., @backend/src). Use "@all" to see a summary of all registered workspaces.',
149
150
  parameters: {
150
151
  type: SchemaType.OBJECT,
151
152
  properties: {
152
153
  dirPath: {
153
154
  type: SchemaType.STRING,
154
- description: 'Relative path to the directory from the workspace root. Use "." for the root.',
155
+ description: 'Relative path to the directory from the workspace root, @alias/path for external workspaces, or "@all" for all workspaces.',
155
156
  },
156
157
  maxDepth: {
157
158
  type: SchemaType.NUMBER,
@@ -177,7 +178,7 @@ export const toolDeclarations = [
177
178
  },
178
179
  {
179
180
  name: 'grep_search',
180
- description: 'Search for a text pattern across files in the workspace. Returns matching file paths with line numbers and content snippets. Uses Extended Regular Expressions (grep -E). IMPORTANT: grep searches line-by-line. Do NOT search for long lists of Tailwind classes or multi-line strings, as they will fail if line-wrapped. Search for short, unique substrings.',
181
+ description: 'Search for a text pattern across files in the workspace. Returns matching file paths with line numbers and content snippets. Uses Extended Regular Expressions (grep -E). IMPORTANT: grep searches line-by-line. Do NOT search for long lists of Tailwind classes or multi-line strings, as they will fail if line-wrapped. Search for short, unique substrings. Use the workspace parameter to search external workspaces.',
181
182
  parameters: {
182
183
  type: SchemaType.OBJECT,
183
184
  properties: {
@@ -189,6 +190,10 @@ export const toolDeclarations = [
189
190
  type: SchemaType.STRING,
190
191
  description: 'Optional glob to restrict file types, e.g. "*.ts" or "*.py". Defaults to all files.',
191
192
  },
193
+ workspace: {
194
+ type: SchemaType.STRING,
195
+ description: 'Optional. Workspace alias to search in (e.g., "backend"), or "all" to search all registered workspaces. Defaults to the primary workspace.',
196
+ },
192
197
  },
193
198
  required: ['pattern'],
194
199
  },
@@ -889,36 +894,145 @@ export async function runDebugScript(workspaceRoot, language, code, abortSignal)
889
894
  }
890
895
  }
891
896
  }
897
+ /**
898
+ * Resolves `@alias/` prefixed paths in tool arguments to the correct workspace root
899
+ * and relative path. Returns the effective workspaceRoot and the cleaned arguments.
900
+ */
901
+ function resolveWorkspaceArgs(primaryRoot, toolName, args) {
902
+ // Only resolve if workspaces are registered
903
+ if (!workspaceRegistry.hasWorkspaces()) {
904
+ return { effectiveRoot: primaryRoot, resolvedArgs: args };
905
+ }
906
+ const resolvedArgs = { ...args };
907
+ // Map of tool-name -> argument keys that hold file/dir paths
908
+ const pathArgKeys = {
909
+ read_file: ['filePath'],
910
+ write_file: ['filePath'],
911
+ delete_file: ['filePath'],
912
+ modify_file: ['filePath'],
913
+ list_directory: ['dirPath'],
914
+ find_dependencies: ['filePath'],
915
+ find_recent_changes: ['dirPath'],
916
+ rename_file: ['sourcePath', 'targetPath'],
917
+ };
918
+ const keys = pathArgKeys[toolName];
919
+ if (!keys) {
920
+ return { effectiveRoot: primaryRoot, resolvedArgs };
921
+ }
922
+ let effectiveRoot = primaryRoot;
923
+ for (const key of keys) {
924
+ const val = resolvedArgs[key];
925
+ if (typeof val !== 'string' || !val.startsWith('@'))
926
+ continue;
927
+ // Special case: "@all" for list_directory — handled separately in executeTool
928
+ if (val === '@all')
929
+ continue;
930
+ const resolved = resolveAndValidateMultiWorkspacePath(primaryRoot, val);
931
+ effectiveRoot = resolved.workspaceRoot;
932
+ resolvedArgs[key] = resolved.relativePath;
933
+ }
934
+ return { effectiveRoot, resolvedArgs };
935
+ }
936
+ /**
937
+ * Runs grep across all registered workspaces (primary + external) and merges results.
938
+ * Results from external workspaces are prefixed with @alias/ for disambiguation.
939
+ */
940
+ async function crossWorkspaceGrep(primaryRoot, pattern, fileGlob, abortSignal) {
941
+ const allRoots = workspaceRegistry.getAllRoots(primaryRoot);
942
+ const allResults = [];
943
+ for (const { alias, root } of allRoots) {
944
+ const result = await grepSearch(root, pattern, fileGlob, abortSignal);
945
+ if (result.error)
946
+ continue;
947
+ // Extract the raw text from the XML wrapper
948
+ const cdataMatch = result.output.match(/<!\[CDATA\[\n([\s\S]*?)\n\]\]>/);
949
+ const rawText = cdataMatch ? cdataMatch[1] : result.output;
950
+ if (rawText.startsWith('No matches found'))
951
+ continue;
952
+ // Prefix each result line with the workspace alias for disambiguation
953
+ const prefix = alias ? `@${alias}/` : '';
954
+ const lines = rawText.split('\n').filter((l) => l.trim());
955
+ for (const line of lines) {
956
+ if (line.startsWith('...')) {
957
+ allResults.push(line);
958
+ }
959
+ else {
960
+ // Lines are in format "./path:lineNum:content" — prepend alias prefix
961
+ allResults.push(prefix ? line.replace(/^\.?\//, `@${alias}/`) : line);
962
+ }
963
+ }
964
+ }
965
+ if (allResults.length === 0) {
966
+ return { output: `No matches found for "${pattern}" across all workspaces.` };
967
+ }
968
+ const limited = allResults.slice(0, 80);
969
+ const resultText = limited.join('\n') +
970
+ (allResults.length > 80 ? `\n\n... (${allResults.length - 80} more results truncated)` : '');
971
+ const wrappedResult = `<workspace_file path="grep_search_results">\n<content_data><![CDATA[\n${sanitizeForCDATA(resultText)}\n]]></content_data>\n</workspace_file>`;
972
+ return { output: wrappedResult };
973
+ }
892
974
  export async function executeTool(workspaceRoot, toolName, args, abortSignal) {
975
+ // ─── Multi-Workspace Path Resolution ─────────────────────────────
976
+ // Intercept @alias/ prefixed paths and swap workspaceRoot + relative path
977
+ // before dispatching to the underlying tool functions (which remain unchanged).
978
+ const { effectiveRoot, resolvedArgs } = resolveWorkspaceArgs(workspaceRoot, toolName, args);
893
979
  let result;
894
980
  switch (toolName) {
895
981
  case 'read_file':
896
- result = await readFile(workspaceRoot, args.filePath, args.startLine, args.endLine, args.targetElements);
982
+ result = await readFile(effectiveRoot, resolvedArgs.filePath, resolvedArgs.startLine, resolvedArgs.endLine, resolvedArgs.targetElements);
897
983
  break;
898
984
  case 'write_file':
899
- result = await writeFile(workspaceRoot, args.filePath, args.content);
985
+ result = await writeFile(effectiveRoot, resolvedArgs.filePath, resolvedArgs.content);
900
986
  break;
901
987
  case 'delete_file':
902
- result = await deleteFile(workspaceRoot, args.filePath);
988
+ result = await deleteFile(effectiveRoot, resolvedArgs.filePath);
903
989
  break;
904
990
  case 'rename_file':
905
- result = await renameFile(workspaceRoot, args.sourcePath, args.targetPath);
991
+ result = await renameFile(effectiveRoot, resolvedArgs.sourcePath, resolvedArgs.targetPath);
906
992
  break;
907
993
  case 'modify_file':
908
- result = await modifyFile(workspaceRoot, args.filePath, args.edits);
994
+ result = await modifyFile(effectiveRoot, resolvedArgs.filePath, resolvedArgs.edits);
909
995
  break;
910
- case 'list_directory':
911
- result = await listDirectory(workspaceRoot, args.dirPath, args.maxDepth ?? 3);
996
+ case 'list_directory': {
997
+ const dirPath = resolvedArgs.dirPath;
998
+ // Handle @all — list all registered workspace roots
999
+ if (dirPath === '@all') {
1000
+ const allRoots = workspaceRegistry.getAllRoots(workspaceRoot);
1001
+ const sections = [];
1002
+ for (const { alias, root } of allRoots) {
1003
+ const label = alias ? `@${alias}` : '(primary)';
1004
+ const subResult = await listDirectory(root, '.', 2);
1005
+ sections.push(`=== Workspace: ${label} [${root}] ===\n${subResult.output}`);
1006
+ }
1007
+ result = { output: sections.join('\n\n') };
1008
+ }
1009
+ else {
1010
+ result = await listDirectory(effectiveRoot, dirPath, resolvedArgs.maxDepth ?? 3);
1011
+ }
912
1012
  break;
1013
+ }
913
1014
  case 'run_command':
914
- result = await runCommand(workspaceRoot, args.command, abortSignal);
1015
+ result = await runCommand(effectiveRoot, resolvedArgs.command, abortSignal);
915
1016
  break;
916
1017
  case 'run_debug_script':
917
- result = await runDebugScript(workspaceRoot, args.language, args.code, abortSignal);
1018
+ result = await runDebugScript(effectiveRoot, resolvedArgs.language, resolvedArgs.code, abortSignal);
918
1019
  break;
919
- case 'grep_search':
920
- result = await grepSearch(workspaceRoot, args.pattern, args.fileGlob, abortSignal);
1020
+ case 'grep_search': {
1021
+ const wsParam = resolvedArgs.workspace;
1022
+ if (wsParam === 'all') {
1023
+ // Cross-workspace search across all registered workspaces
1024
+ result = await crossWorkspaceGrep(workspaceRoot, resolvedArgs.pattern, resolvedArgs.fileGlob, abortSignal);
1025
+ }
1026
+ else if (wsParam && workspaceRegistry.get(wsParam)) {
1027
+ // Search in a specific external workspace
1028
+ const ws = workspaceRegistry.get(wsParam);
1029
+ result = await grepSearch(ws.absolutePath, resolvedArgs.pattern, resolvedArgs.fileGlob, abortSignal);
1030
+ }
1031
+ else {
1032
+ result = await grepSearch(effectiveRoot, resolvedArgs.pattern, resolvedArgs.fileGlob, abortSignal);
1033
+ }
921
1034
  break;
1035
+ }
922
1036
  case 'semantic_search': {
923
1037
  const query = args.query;
924
1038
  const topK = args.topK || 5;
@@ -950,10 +1064,10 @@ export async function executeTool(workspaceRoot, toolName, args, abortSignal) {
950
1064
  break;
951
1065
  }
952
1066
  case 'find_dependencies':
953
- result = await traceDependencies(workspaceRoot, args.filePath, args.direction, args.maxDepth);
1067
+ result = await traceDependencies(effectiveRoot, resolvedArgs.filePath, resolvedArgs.direction, resolvedArgs.maxDepth);
954
1068
  break;
955
1069
  case 'find_recent_changes':
956
- result = await findRecentChanges(workspaceRoot, args.dirPath, args.minutes, args.maxDepth);
1070
+ result = await findRecentChanges(effectiveRoot, resolvedArgs.dirPath, resolvedArgs.minutes, resolvedArgs.maxDepth);
957
1071
  break;
958
1072
  default:
959
1073
  result = { output: '', error: `Unknown tool: "${toolName}"` };
@@ -140,6 +140,7 @@ export async function startAgentLoop(workspaceRoot, version) {
140
140
  label: '/semantic-search',
141
141
  hint: 'Toggle local vector index capabilities for better search',
142
142
  },
143
+ { value: '/workspaces', label: '/workspaces', hint: 'Manage external workspaces for cross-project development' },
143
144
  { value: '/stats', label: '/stats', hint: 'View current session statistics and configuration' },
144
145
  { value: '/commit', label: '/commit', hint: 'Auto-commit changes with AI message' },
145
146
  { value: '/revert', label: '/revert', hint: 'Undo last change' },
@@ -306,6 +307,7 @@ export async function executeSingleTurn(workspaceRoot, userInput, chat, inputHan
306
307
  selectedModel = GEMINI_MODELS.FLASH_LITE_3_1;
307
308
  }
308
309
  else {
310
+ spinner.stop(); // MUST clear the investigation spinner first to prevent leaking the setInterval
309
311
  spinner.start(pc.blue('🧠 Evaluating execution complexity...'));
310
312
  const complexity = await evaluateExecutionComplexity(finalInput, gatherRes.contextResult?.summary, gatherRes.contextResult?.relevantFiles?.size || 0);
311
313
  spinner.stop();
@@ -331,6 +333,7 @@ export async function executeSingleTurn(workspaceRoot, userInput, chat, inputHan
331
333
  else {
332
334
  // Parallel investigation already printed its own detailed summary log
333
335
  if (!inputHandler.isCurrentlyPrompting()) {
336
+ spinner.stop(); // MUST clear the investigation spinner first to prevent leaking the setInterval
334
337
  spinner.start('Thinking...');
335
338
  }
336
339
  }