{"version":3,"file":"footer-data-provider.d.ts","sourceRoot":"","sources":["../../src/core/footer-data-provider.ts"],"names":[],"mappings":"AAWA,mEAAmE;AACnE,MAAM,WAAW,aAAa;IAC7B,qCAAqC;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,uBAAuB;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,yCAAyC;IACzC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,sCAAsC;IACtC,MAAM,EAAE,OAAO,CAAC;IAChB,4BAA4B;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,wCAAwC;IACxC,QAAQ,EAAE,OAAO,CAAC;CAClB;AAED,yDAAyD;AACzD,MAAM,WAAW,oBAAoB;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,qFAAmF;IACnF,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,2DAA2D;IAC3D,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,sFAAsF;IACtF,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,OAAO,CAAC;CACxB;AAmDD;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,aAAa,EAAE,CAY9D;AAkDD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,yBAAyB,CAAC,UAAU,EAAE,MAAM,GAAG,oBAAoB,CA0ElF;AAuBD;;;GAGG;AACH,qBAAa,kBAAkB;IAC9B,OAAO,CAAC,iBAAiB,CAA6B;IACtD,OAAO,CAAC,YAAY,CAAwC;IAC5D,OAAO,CAAC,QAAQ,CAA0C;IAC1D,OAAO,CAAC,WAAW,CAA0B;IAC7C,OAAO,CAAC,eAAe,CAA0B;IACjD,OAAO,CAAC,qBAAqB,CAAyB;IACtD,OAAO,CAAC,sBAAsB,CAAK;IAEnC,cAGC;IAED,2EAA2E;IAC3E,YAAY,IAAI,MAAM,GAAG,IAAI,CAK5B;IAED,iEAAiE;IACjE,cAAc,IAAI,MAAM,GAAG,IAAI,CAE9B;IAED,wDAAwD;IACxD,oBAAoB,IAAI,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAElD;IAED,qEAAqE;IACrE,cAAc,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAG/C;IAED,qCAAqC;IACrC,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAM9D;IAED,yCAAyC;IACzC,sBAAsB,IAAI,IAAI,CAE7B;IAED,4EAA4E;IAC5E,yBAAyB,IAAI,MAAM,CAElC;IAED,gDAAgD;IAChD,yBAAyB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAE7C;IAED,wBAAwB;IACxB,OAAO,IAAI,IAAI,CAUd;IAED,OAAO,CAAC,kBAAkB;IAI1B,OAAO,CAAC,gBAAgB;IAUxB,OAAO,CAAC,gBAAgB;IAcxB,OAAO,CAAC,eAAe;CA6BvB;AAED,yGAAyG;AACzG,MAAM,MAAM,0BAA0B,GAAG,IAAI,CAC5C,kBAAkB,EAClB,cAAc,GAAG,gBAAgB,GAAG,sBAAsB,GAAG,2BAA2B,GAAG,gBAAgB,CAC3G,CAAC","sourcesContent":["import { spawnSync } from \"child_process\";\nimport { existsSync, type FSWatcher, readFileSync, statSync, watch } from \"fs\";\nimport { hostname } from \"os\";\nimport { basename, dirname, join, resolve } from \"path\";\n\ntype GitPaths = {\n\trepoDir: string;\n\tcommonGitDir: string;\n\theadPath: string;\n};\n\n/** Structured worktree entry from git worktree list --porcelain */\nexport interface WorktreeEntry {\n\t/** Absolute path to worktree root */\n\tpath: string;\n\t/** HEAD commit hash */\n\thead: string;\n\t/** Branch name, null if detached HEAD */\n\tbranch: string | null;\n\t/** Whether this worktree is locked */\n\tlocked: boolean;\n\t/** Lock reason if locked */\n\tlockReason?: string;\n\t/** Whether this worktree is prunable */\n\tprunable: boolean;\n}\n\n/** Structured execution environment for agent context */\nexport interface ExecutionEnvironment {\n\thost: string;\n\tos: string;\n\t/** Login shell from $SHELL or platform default — may differ from the tool shell */\n\tloginShell: string;\n\tinitialCwd: string;\n\teffectiveCwd: string;\n\t/** Git root of the effective working directory, or null */\n\tgitRoot: string | null;\n\t/** Git root of the initial working directory, only set when different from gitRoot */\n\tcontrollerGitRoot: string | null;\n\tgitBranch: string | null;\n\tgitWorktree: string | null;\n\tworktreeCount: number;\n\tisDetachedHead: boolean;\n}\n\n/**\n * Find git metadata paths by walking up from cwd.\n * Handles both regular git repos (.git is a directory) and worktrees (.git is a file).\n */\nfunction findGitPaths(): GitPaths | null {\n\tlet dir = process.cwd();\n\twhile (true) {\n\t\tconst gitPath = join(dir, \".git\");\n\t\tif (existsSync(gitPath)) {\n\t\t\ttry {\n\t\t\t\tconst stat = statSync(gitPath);\n\t\t\t\tif (stat.isFile()) {\n\t\t\t\t\tconst content = readFileSync(gitPath, \"utf8\").trim();\n\t\t\t\t\tif (content.startsWith(\"gitdir: \")) {\n\t\t\t\t\t\tconst gitDir = resolve(dir, content.slice(8).trim());\n\t\t\t\t\t\tconst headPath = join(gitDir, \"HEAD\");\n\t\t\t\t\t\tif (!existsSync(headPath)) return null;\n\t\t\t\t\t\tconst commonDirPath = join(gitDir, \"commondir\");\n\t\t\t\t\t\tconst commonGitDir = existsSync(commonDirPath)\n\t\t\t\t\t\t\t? resolve(gitDir, readFileSync(commonDirPath, \"utf8\").trim())\n\t\t\t\t\t\t\t: gitDir;\n\t\t\t\t\t\treturn { repoDir: dir, commonGitDir, headPath };\n\t\t\t\t\t}\n\t\t\t\t} else if (stat.isDirectory()) {\n\t\t\t\t\tconst headPath = join(gitPath, \"HEAD\");\n\t\t\t\t\tif (!existsSync(headPath)) return null;\n\t\t\t\t\treturn { repoDir: dir, commonGitDir: gitPath, headPath };\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t}\n\t\tconst parent = dirname(dir);\n\t\tif (parent === dir) return null;\n\t\tdir = parent;\n\t}\n}\n\n/** Ask git for the current branch. Returns null on detached HEAD or if git is unavailable. */\nfunction resolveBranchWithGit(repoDir: string): string | null {\n\tconst result = spawnSync(\"git\", [\"--no-optional-locks\", \"symbolic-ref\", \"--quiet\", \"--short\", \"HEAD\"], {\n\t\tcwd: repoDir,\n\t\tencoding: \"utf8\",\n\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t});\n\tconst branch = result.status === 0 ? result.stdout.trim() : \"\";\n\treturn branch || null;\n}\n\n/**\n * Parse git worktree list --porcelain output into structured entries.\n * Returns empty array if git is unavailable or the repo has no worktrees.\n */\nexport function parseWorktreeList(cwd: string): WorktreeEntry[] {\n\ttry {\n\t\tconst result = spawnSync(\"git\", [\"worktree\", \"list\", \"--porcelain\"], {\n\t\t\tcwd,\n\t\t\tencoding: \"utf8\",\n\t\t\tstdio: [\"ignore\", \"pipe\", \"pipe\"],\n\t\t});\n\t\tif (result.status !== 0) return [];\n\t\treturn parseWorktreePorcelain(result.stdout);\n\t} catch {\n\t\treturn [];\n\t}\n}\n\n/**\n * Parse porcelain worktree output into structured entries.\n * Format: https://git-scm.com/docs/git-worktree#_porcelain_format\n */\nfunction parseWorktreePorcelain(output: string): WorktreeEntry[] {\n\tconst entries: WorktreeEntry[] = [];\n\tlet current: Partial<WorktreeEntry> = {};\n\n\tfor (const line of output.split(\"\\n\")) {\n\t\tif (line === \"\") {\n\t\t\t// Empty line = end of entry\n\t\t\tif (current.path && current.head) {\n\t\t\t\tentries.push({\n\t\t\t\t\tpath: current.path,\n\t\t\t\t\thead: current.head,\n\t\t\t\t\tbranch: current.branch ?? null,\n\t\t\t\t\tlocked: current.locked ?? false,\n\t\t\t\t\tlockReason: current.lockReason,\n\t\t\t\t\tprunable: current.prunable ?? false,\n\t\t\t\t});\n\t\t\t}\n\t\t\tcurrent = {};\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (line.startsWith(\"worktree \")) {\n\t\t\tcurrent.path = line.slice(9);\n\t\t} else if (line.startsWith(\"HEAD \")) {\n\t\t\tcurrent.head = line.slice(5);\n\t\t} else if (line.startsWith(\"branch \")) {\n\t\t\tconst branchRef = line.slice(7);\n\t\t\tcurrent.branch = branchRef.startsWith(\"refs/heads/\") ? branchRef.slice(11) : branchRef;\n\t\t} else if (line.startsWith(\"detached\")) {\n\t\t\tcurrent.branch = null;\n\t\t} else if (line.startsWith(\"locked\")) {\n\t\t\tcurrent.locked = true;\n\t\t\tconst spaceIdx = line.indexOf(\" \", 7);\n\t\t\tif (spaceIdx > 0) {\n\t\t\t\tcurrent.lockReason = line.slice(spaceIdx + 1);\n\t\t\t}\n\t\t} else if (line.startsWith(\"prunable \")) {\n\t\t\tcurrent.prunable = true;\n\t\t}\n\t}\n\n\treturn entries;\n}\n\n/**\n * Build a concise execution environment summary for the agent prompt.\n *\n * Sources:\n * - os.hostname() for host\n * - process.platform for OS (linux, darwin, win32)\n * - process.env.SHELL or platform default for shell\n * - initialCwd must be captured at session start and passed in\n * - process.cwd() for effective cwd\n * - git rev-parse for repo root\n * - git worktree list for worktree info\n *\n * Never includes secrets, full env vars, or sensitive data.\n */\nexport function buildExecutionEnvironment(initialCwd: string): ExecutionEnvironment {\n\tconst osPlatform = process.platform;\n\tconst osName = osPlatform === \"win32\" ? \"Windows\" : osPlatform === \"darwin\" ? \"macOS\" : \"Linux\";\n\n\tconst loginShell = process.env.SHELL || (osPlatform === \"win32\" ? \"powershell\" : \"/bin/sh\");\n\n\tconst effectiveCwd = process.cwd();\n\n\tlet gitRoot: string | null = null;\n\tlet controllerGitRoot: string | null = null;\n\tlet gitBranch: string | null = null;\n\tlet gitWorktree: string | null = null;\n\tlet worktreeCount = 0;\n\tlet isDetachedHead = false;\n\n\ttry {\n\t\t// Effective repository (where commands actually run)\n\t\tconst topLevel = spawnSync(\"git\", [\"rev-parse\", \"--show-toplevel\"], {\n\t\t\tcwd: effectiveCwd,\n\t\t\tencoding: \"utf8\",\n\t\t\tstdio: [\"ignore\", \"pipe\", \"pipe\"],\n\t\t});\n\t\tif (topLevel.status === 0) {\n\t\t\tgitRoot = topLevel.stdout.trim();\n\t\t}\n\n\t\t// Controller repository (where Jensen was started), only if different from effective\n\t\tif (initialCwd !== effectiveCwd) {\n\t\t\tconst controllerTopLevel = spawnSync(\"git\", [\"rev-parse\", \"--show-toplevel\"], {\n\t\t\t\tcwd: initialCwd,\n\t\t\t\tencoding: \"utf8\",\n\t\t\t\tstdio: [\"ignore\", \"pipe\", \"pipe\"],\n\t\t\t});\n\t\t\tif (controllerTopLevel.status === 0) {\n\t\t\t\tconst candidate = controllerTopLevel.stdout.trim();\n\t\t\t\tif (candidate !== gitRoot) {\n\t\t\t\t\tcontrollerGitRoot = candidate;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tconst branch = spawnSync(\"git\", [\"branch\", \"--show-current\"], {\n\t\t\tcwd: effectiveCwd,\n\t\t\tencoding: \"utf8\",\n\t\t\tstdio: [\"ignore\", \"pipe\", \"pipe\"],\n\t\t});\n\t\tif (branch.status === 0) {\n\t\t\tconst branchName = branch.stdout.trim();\n\t\t\tgitBranch = branchName || null;\n\t\t\tif (!branchName) isDetachedHead = true;\n\t\t}\n\n\t\tif (gitRoot) {\n\t\t\tconst worktrees = parseWorktreeList(effectiveCwd);\n\t\t\tworktreeCount = worktrees.length;\n\t\t\tgitWorktree = findCurrentWorktree(effectiveCwd, worktrees);\n\t\t}\n\t} catch {\n\t\t// Not a git repo or git unavailable\n\t}\n\n\treturn {\n\t\thost: hostname(),\n\t\tos: osName,\n\t\tloginShell,\n\t\tinitialCwd,\n\t\teffectiveCwd,\n\t\tgitRoot,\n\t\tcontrollerGitRoot,\n\t\tgitBranch,\n\t\tgitWorktree,\n\t\tworktreeCount,\n\t\tisDetachedHead,\n\t};\n}\n\n/**\n * Find which worktree entry corresponds to the current directory.\n */\nfunction findCurrentWorktree(cwd: string, worktrees: WorktreeEntry[]): string | null {\n\t// Normalize cwd for comparison (no trailing slash)\n\tconst normalizedCwd = cwd.replace(/[/\\\\]$/, \"\");\n\tfor (const wt of worktrees) {\n\t\tconst normalizedPath = wt.path.replace(/[/\\\\]$/, \"\");\n\t\tif (normalizedCwd === normalizedPath) {\n\t\t\treturn wt.path;\n\t\t}\n\t}\n\t// If no exact match, check if cwd is a subdirectory of a worktree\n\tfor (const wt of worktrees) {\n\t\tif (cwd.startsWith(`${wt.path}/`) || cwd.startsWith(`${wt.path}\\\\`)) {\n\t\t\treturn wt.path;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Provides git branch and extension statuses - data not otherwise accessible to extensions.\n * Token stats, model info available via ctx.sessionManager and ctx.model.\n */\nexport class FooterDataProvider {\n\tprivate extensionStatuses = new Map<string, string>();\n\tprivate cachedBranch: string | null | undefined = undefined;\n\tprivate gitPaths: GitPaths | null | undefined = undefined;\n\tprivate headWatcher: FSWatcher | null = null;\n\tprivate reftableWatcher: FSWatcher | null = null;\n\tprivate branchChangeCallbacks = new Set<() => void>();\n\tprivate availableProviderCount = 0;\n\n\tconstructor() {\n\t\tthis.gitPaths = findGitPaths();\n\t\tthis.setupGitWatcher();\n\t}\n\n\t/** Current git branch, null if not in repo, \"detached\" if detached HEAD */\n\tgetGitBranch(): string | null {\n\t\tif (this.cachedBranch === undefined) {\n\t\t\tthis.cachedBranch = this.resolveGitBranch();\n\t\t}\n\t\treturn this.cachedBranch;\n\t}\n\n\t/** Repository directory name, null if not in a git repository */\n\tgetGitRepoName(): string | null {\n\t\treturn this.gitPaths ? basename(this.gitPaths.repoDir) : null;\n\t}\n\n\t/** Extension status texts set via ctx.ui.setStatus() */\n\tgetExtensionStatuses(): ReadonlyMap<string, string> {\n\t\treturn this.extensionStatuses;\n\t}\n\n\t/** Subscribe to git branch changes. Returns unsubscribe function. */\n\tonBranchChange(callback: () => void): () => void {\n\t\tthis.branchChangeCallbacks.add(callback);\n\t\treturn () => this.branchChangeCallbacks.delete(callback);\n\t}\n\n\t/** Internal: set extension status */\n\tsetExtensionStatus(key: string, text: string | undefined): void {\n\t\tif (text === undefined) {\n\t\t\tthis.extensionStatuses.delete(key);\n\t\t} else {\n\t\t\tthis.extensionStatuses.set(key, text);\n\t\t}\n\t}\n\n\t/** Internal: clear extension statuses */\n\tclearExtensionStatuses(): void {\n\t\tthis.extensionStatuses.clear();\n\t}\n\n\t/** Number of unique providers with available models (for footer display) */\n\tgetAvailableProviderCount(): number {\n\t\treturn this.availableProviderCount;\n\t}\n\n\t/** Internal: update available provider count */\n\tsetAvailableProviderCount(count: number): void {\n\t\tthis.availableProviderCount = count;\n\t}\n\n\t/** Internal: cleanup */\n\tdispose(): void {\n\t\tif (this.headWatcher) {\n\t\t\tthis.headWatcher.close();\n\t\t\tthis.headWatcher = null;\n\t\t}\n\t\tif (this.reftableWatcher) {\n\t\t\tthis.reftableWatcher.close();\n\t\t\tthis.reftableWatcher = null;\n\t\t}\n\t\tthis.branchChangeCallbacks.clear();\n\t}\n\n\tprivate notifyBranchChange(): void {\n\t\tfor (const cb of this.branchChangeCallbacks) cb();\n\t}\n\n\tprivate refreshGitBranch(): void {\n\t\tconst nextBranch = this.resolveGitBranch();\n\t\tif (this.cachedBranch !== undefined && this.cachedBranch !== nextBranch) {\n\t\t\tthis.cachedBranch = nextBranch;\n\t\t\tthis.notifyBranchChange();\n\t\t\treturn;\n\t\t}\n\t\tthis.cachedBranch = nextBranch;\n\t}\n\n\tprivate resolveGitBranch(): string | null {\n\t\ttry {\n\t\t\tif (!this.gitPaths) return null;\n\t\t\tconst content = readFileSync(this.gitPaths.headPath, \"utf8\").trim();\n\t\t\tif (content.startsWith(\"ref: refs/heads/\")) {\n\t\t\t\tconst branch = content.slice(16);\n\t\t\t\treturn branch === \".invalid\" ? (resolveBranchWithGit(this.gitPaths.repoDir) ?? \"detached\") : branch;\n\t\t\t}\n\t\t\treturn \"detached\";\n\t\t} catch {\n\t\t\treturn null;\n\t\t}\n\t}\n\n\tprivate setupGitWatcher(): void {\n\t\tif (!this.gitPaths) return;\n\n\t\t// Watch the directory containing HEAD, not HEAD itself.\n\t\t// Git uses atomic writes (write temp, rename over HEAD), which changes the inode.\n\t\t// fs.watch on a file stops working after the inode changes.\n\t\ttry {\n\t\t\tthis.headWatcher = watch(dirname(this.gitPaths.headPath), (_eventType, filename) => {\n\t\t\t\tif (!filename || filename.toString() === \"HEAD\") {\n\t\t\t\t\tthis.refreshGitBranch();\n\t\t\t\t}\n\t\t\t});\n\t\t} catch {\n\t\t\t// Silently fail if we can't watch\n\t\t}\n\n\t\t// In reftable repos, branch switches update files in the reftable directory\n\t\t// instead of HEAD. Watch it separately so the footer picks up those changes.\n\t\tconst reftableDir = join(this.gitPaths.commonGitDir, \"reftable\");\n\t\tif (existsSync(reftableDir)) {\n\t\t\ttry {\n\t\t\t\tthis.reftableWatcher = watch(reftableDir, () => {\n\t\t\t\t\tthis.refreshGitBranch();\n\t\t\t\t});\n\t\t\t} catch {\n\t\t\t\t// Silently fail if we can't watch\n\t\t\t}\n\t\t}\n\t}\n}\n\n/** Read-only view for extensions - excludes setExtensionStatus, setAvailableProviderCount and dispose */\nexport type ReadonlyFooterDataProvider = Pick<\n\tFooterDataProvider,\n\t\"getGitBranch\" | \"getGitRepoName\" | \"getExtensionStatuses\" | \"getAvailableProviderCount\" | \"onBranchChange\"\n>;\n"]}