@@ -30,22 +30,40 @@ import { readSettings, writeSettings } from "../shared/config-io";
3030const WORKSPACE_DIR = join ( homedir ( ) , ".pi" , "agent" , "workspaces" ) ;
3131const WORKSPACE_EXT = ".ws.json" ;
3232
33+ // Maximum file size to archive (100KB)
34+ const MAX_FILE_SIZE = 100 * 1024 ;
35+ // File extensions to skip when archiving
36+ const SKIP_EXTENSIONS = [ ".log" , ".tmp" , ".cache" , ".lock" , ".swp" , ".swo" ] ;
37+
3338// ============================================================================
3439// Types
3540// ============================================================================
3641
42+ interface WorkspaceExtension {
43+ name : string ;
44+ source : "local" | "git" | "package" ;
45+ package ?: string ;
46+ }
47+
3748interface WorkspaceState {
3849 name : string ;
3950 savedAt : string ;
4051 session : { sessionName ?: string } ;
4152 skills : string [ ] ;
42- extensions : string [ ] ;
53+ extensions : WorkspaceExtension [ ] ;
4354 configs : Record < string , unknown > ;
4455 soul ?: { name : string ; level : number } ;
4556 cwd ?: string ;
57+ repo ?: string ;
58+ repos ?: { path : string ; remote : string | null } [ ] ;
59+ content ?: WorkspaceContent ;
4660 version : string ;
4761}
4862
63+ interface WorkspaceContent {
64+ files : { path : string ; content : string } [ ] ;
65+ }
66+
4967// ============================================================================
5068// Helpers
5169// ============================================================================
@@ -70,8 +88,8 @@ function saveWorkspaceState(name: string, state: WorkspaceState): boolean {
7088 } catch { return false ; }
7189}
7290
73- function getCurrentExtensions ( ) : string [ ] {
74- const extensions : string [ ] = [ ] ;
91+ function getCurrentExtensions ( ) : WorkspaceExtension [ ] {
92+ const extensions : WorkspaceExtension [ ] = [ ] ;
7593 const seen = new Set < string > ( ) ;
7694
7795 // Check ~/.pi/agent/extensions (local extensions)
@@ -82,7 +100,7 @@ function getCurrentExtensions(): string[] {
82100 if ( entry . endsWith ( ".js" ) || entry . endsWith ( ".mjs" ) ) {
83101 const extName = entry . replace ( / \. ( j s | m j s ) $ / , "" ) ;
84102 if ( ! seen . has ( extName ) ) {
85- extensions . push ( extName ) ;
103+ extensions . push ( { name : extName , source : "local" } ) ;
86104 seen . add ( extName ) ;
87105 }
88106 }
@@ -114,12 +132,18 @@ function getCurrentExtensions(): string[] {
114132 const extPath = join ( userDir , repoEntry . name , "extensions" ) ;
115133 if ( ! existsSync ( extPath ) ) continue ;
116134
135+ const repoUrl = `${ hostEntry . name } /${ userEntry . name } /${ repoEntry . name } ` ;
136+
117137 const extFiles = readdirSync ( extPath ) ;
118138 for ( const entry of extFiles ) {
119139 if ( entry . endsWith ( ".ts" ) || entry . endsWith ( ".js" ) ) {
120140 const extName = entry . replace ( / \. ( t s | j s ) $ / , "" ) ;
121141 if ( ! seen . has ( extName ) ) {
122- extensions . push ( extName ) ;
142+ extensions . push ( {
143+ name : extName ,
144+ source : "git" ,
145+ package : repoUrl
146+ } ) ;
123147 seen . add ( extName ) ;
124148 }
125149 }
@@ -132,6 +156,40 @@ function getCurrentExtensions(): string[] {
132156 debugLog ( "workspace" , "failed to scan git extensions" , err ) ;
133157 }
134158
159+ // Check git packages for extensions in package.json
160+ try {
161+ const packages = readSettings ( ) . packages || [ ] ;
162+ for ( const pkg of packages ) {
163+ // Handle git:github.com/VTSTech/pi-coding-agent format
164+ const gitMatch = pkg . match ( / ^ g i t : ( .+ ) $ / ) ;
165+ if ( gitMatch ) {
166+ const repoPath = gitMatch [ 1 ] ; // e.g., github.com/VTSTech/pi-coding-agent
167+ const [ hostUserRepo ] = repoPath . split ( "/" ) ; // Could be more complex
168+
169+ // Try to get extensions from the git repo
170+ const gitExtPath = join ( homedir ( ) , ".pi" , "agent" , "git" , repoPath , "extensions" ) ;
171+ if ( existsSync ( gitExtPath ) ) {
172+ const extFiles = readdirSync ( gitExtPath ) ;
173+ for ( const entry of extFiles ) {
174+ if ( entry . endsWith ( ".ts" ) || entry . endsWith ( ".js" ) ) {
175+ const extName = entry . replace ( / \. ( t s | j s ) $ / , "" ) ;
176+ if ( ! seen . has ( extName ) ) {
177+ extensions . push ( {
178+ name : extName ,
179+ source : "package" ,
180+ package : repoPath
181+ } ) ;
182+ seen . add ( extName ) ;
183+ }
184+ }
185+ }
186+ }
187+ }
188+ }
189+ } catch ( err ) {
190+ debugLog ( "workspace" , "failed to scan package extensions" , err ) ;
191+ }
192+
135193 return extensions ;
136194}
137195
@@ -162,6 +220,112 @@ function getCurrentSoul(): { name: string; level: number } | null {
162220
163221const BRANDING = `⚡ Pi Workspace Manager v1.3.5 - VTSTech` ;
164222
223+ // ============================================================================
224+ // Git & Content Helpers
225+ // ============================================================================
226+
227+ /**
228+ * Check if a directory is a git repository
229+ */
230+ function isGitRepo ( dir : string ) : boolean {
231+ try {
232+ return existsSync ( join ( dir , ".git" ) ) ;
233+ } catch { return false ; }
234+ }
235+
236+ /**
237+ * Get the git remote URL for a directory, if it's a repo
238+ */
239+ function getGitRemoteUrl ( dir : string ) : string | null {
240+ try {
241+ const result = require ( "child_process" ) . execSync (
242+ `git -C "${ dir } " remote get-url origin 2>/dev/null` ,
243+ { encoding : "utf-8" }
244+ ) . trim ( ) ;
245+ return result || null ;
246+ } catch { return null ; }
247+ }
248+
249+ /**
250+ * Find git repositories within a directory (up to 2 levels deep)
251+ * Skip !dirs (reference folders) but still scan .dirs for repos
252+ */
253+ function findGitRepos ( baseDir : string ) : { path : string ; remote : string | null } [ ] {
254+ const repos : { path : string ; remote : string | null } [ ] = [ ] ;
255+
256+ function scanDir ( dir : string , depth : number ) {
257+ if ( depth > 2 ) return ;
258+
259+ try {
260+ const entries = readdirSync ( dir , { withFileTypes : true } ) ;
261+ for ( const entry of entries ) {
262+ // Skip .git directories and !dirs (reference folders)
263+ if ( entry . name === ".git" || entry . name . startsWith ( "!" ) ) continue ;
264+ if ( ! entry . isDirectory ( ) ) continue ;
265+
266+ const fullPath = join ( dir , entry . name ) ;
267+ if ( isGitRepo ( fullPath ) ) {
268+ repos . push ( { path : fullPath , remote : getGitRemoteUrl ( fullPath ) } ) ;
269+ }
270+ scanDir ( fullPath , depth + 1 ) ;
271+ }
272+ } catch ( err ) {
273+ debugLog ( "workspace" , "failed to scan dir for repos" , err ) ;
274+ }
275+ }
276+
277+ scanDir ( baseDir , 0 ) ;
278+ return repos ;
279+ }
280+
281+ /**
282+ * Get workspace directory, skipping !dirs (reference folders)
283+ * .dirs ARE scanned for content as skills/extensions may live there
284+ */
285+ function getWorkspaceContent ( dir : string ) : WorkspaceContent {
286+ const files : { path : string ; content : string } [ ] = [ ] ;
287+
288+ function scanForFiles ( currentDir : string , basePath : string ) {
289+ try {
290+ const entries = readdirSync ( currentDir , { withFileTypes : true } ) ;
291+ for ( const entry of entries ) {
292+ // Skip .git directories and !dirs (reference folders)
293+ if ( entry . name === ".git" || entry . name . startsWith ( "!" ) ) continue ;
294+
295+ const fullPath = join ( currentDir , entry . name ) ;
296+ const relativePath = join ( basePath , entry . name ) ;
297+
298+ if ( entry . isDirectory ( ) ) {
299+ // Don't skip .dirs - they may contain skills/extensions
300+ scanForFiles ( fullPath , relativePath ) ;
301+ } else if ( entry . isFile ( ) ) {
302+ const ext = "." + entry . name . split ( "." ) . pop ( ) ;
303+ // Skip certain extensions and large files
304+ if ( SKIP_EXTENSIONS . includes ( ext ) || entry . name . endsWith ( ".png" ) || entry . name . endsWith ( ".jpg" ) || entry . name . endsWith ( ".gif" ) ) continue ;
305+
306+ try {
307+ const stats = require ( "fs" ) . statSync ( fullPath ) ;
308+ if ( stats . size > MAX_FILE_SIZE ) continue ;
309+
310+ const content = readFileSync ( fullPath , "utf-8" ) ;
311+ // Skip binary content (but allow .ts/.js even if they have nulls)
312+ if ( content . includes ( "\x00" ) && ext !== ".ts" && ext !== ".js" ) continue ;
313+
314+ files . push ( { path : relativePath , content } ) ;
315+ } catch ( err ) {
316+ debugLog ( "workspace" , `failed to read file ${ fullPath } ` , err ) ;
317+ }
318+ }
319+ }
320+ } catch ( err ) {
321+ debugLog ( "workspace" , "failed to scan workspace content" , err ) ;
322+ }
323+ }
324+
325+ scanForFiles ( dir , "" ) ;
326+ return { files } ;
327+ }
328+
165329// ============================================================================
166330// Extension
167331// ============================================================================
@@ -198,17 +362,36 @@ export default function (pi: ExtensionAPI) {
198362 } ) ;
199363
200364 async function handleSave ( ctx : any , name : string ) {
365+ const cwd = process . cwd ( ) ;
366+ const repos = findGitRepos ( cwd ) ;
367+ const isCurrentDirRepo = isGitRepo ( cwd ) ;
368+
201369 const state : WorkspaceState = {
202370 name, savedAt : new Date ( ) . toISOString ( ) ,
203371 session : { sessionName : undefined } ,
204372 skills : getCurrentSkills ( ) ,
205373 extensions : getCurrentExtensions ( ) ,
206374 configs : readSettings ( ) ,
207375 soul : getCurrentSoul ( ) ,
208- cwd : process . cwd ( ) ,
376+ cwd,
209377 version : "1.0.0" ,
210378 } ;
211379
380+ // If current directory is a git repo, save its URL
381+ if ( isCurrentDirRepo ) {
382+ state . repo = getGitRemoteUrl ( cwd ) ;
383+ }
384+
385+ // If git repos are found within the workspace, save their info
386+ if ( repos . length > 0 ) {
387+ state . repos = repos ;
388+ }
389+
390+ // Only archive content if there are no repos (git repos can be cloned)
391+ if ( ! isCurrentDirRepo && repos . length === 0 ) {
392+ state . content = getWorkspaceContent ( cwd ) ;
393+ }
394+
212395 if ( saveWorkspaceState ( name , state ) ) {
213396 ctx . ui . notify ( `Saved workspace "${ name } "` , "success" ) ;
214397 }
@@ -249,10 +432,27 @@ export default function (pi: ExtensionAPI) {
249432 async function handleCurrent ( ctx : any ) {
250433 const exts = getCurrentExtensions ( ) ;
251434 const skills = getCurrentSkills ( ) ;
435+ const cwd = process . cwd ( ) ;
436+ const repos = findGitRepos ( cwd ) ;
437+ const isCurrentRepo = isGitRepo ( cwd ) ;
252438
253439 let output = `${ BRANDING } \n\n` ;
254440 output += `Extensions: ${ exts . length } \n` ;
255- output += `Skills: ${ skills . length } \n` ;
441+ for ( const ext of exts ) {
442+ const sourceInfo = ext . source === "local" ? "(local)" : ext . package ? `(${ ext . package } )` : "" ;
443+ output += ` - ${ ext . name } ${ sourceInfo } \n` ;
444+ }
445+ output += `\nSkills: ${ skills . length } \n` ;
446+ for ( const skill of skills ) {
447+ output += ` - ${ skill } \n` ;
448+ }
449+ output += `\nRepos found: ${ repos . length } \n` ;
450+ for ( const repo of repos ) {
451+ output += ` - ${ repo . path } ${ repo . remote ? `(${ repo . remote } )` : "" } \n` ;
452+ }
453+ if ( isCurrentRepo ) {
454+ output += ` - (current dir) ${ getGitRemoteUrl ( cwd ) } \n` ;
455+ }
256456
257457 pi . sendMessage ( {
258458 customType : "workspace-current" ,
0 commit comments