@hank-warren/pi-statusline 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hank Warren
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # pi-statusline
2
+
3
+ Replaces Pi's default footer with a compact statusline:
4
+
5
+ ```text
6
+ gpt-5.6-sol | pi-extensions:main* ⇣1 | 40k/1.0m
7
+ ⑂ pi-extensions:feature/statusline* ⇣2 #7 | infra:fix/alerts #168
8
+ 019fafa7-29c0-7e99-9f82-5794d5721848
9
+ ```
10
+
11
+ ## What it shows
12
+
13
+ - **Line 1** — active model ID, current directory basename and Git branch, and current context usage/window. A yellow `*` marks a dirty checkout and `⇣N` shows how many commits it is behind its locally known upstream ref. Unknown context usage is rendered as `?/<window>` until Pi can provide an estimate.
14
+ - **Worktree lines** — when the session works in or sends tool calls into linked worktrees, one line shows the same branch/dirty/behind state for each worktree plus its associated PR number.
15
+ - **Final line** — the full Pi session ID.
16
+
17
+ It uses a fixed true-color palette with context warning thresholds.
18
+
19
+ ## Worktree/PR tracking behavior
20
+
21
+ - Only worktrees touched on the active Pi session branch are included.
22
+ - Worktree paths appearing only inside Bash heredoc payloads are ignored.
23
+ - Merged and closed PR worktrees are hidden.
24
+ - State is rebuilt on reload and tree navigation; deleted worktrees are pruned.
25
+ - Git state refreshes after each turn; branches without a PR are rechecked every five seconds; existing PR metadata is cached for five minutes.
26
+
27
+ PR lookups use the `gh` CLI when available and degrade gracefully without it.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pi install npm:@hank-warren/pi-statusline
33
+ ```
34
+
35
+ ## License
36
+
37
+ MIT — see [LICENSE](LICENSE).
package/index.ts ADDED
@@ -0,0 +1,212 @@
1
+ import { basename } from "node:path";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { truncateToWidth } from "@earendil-works/pi-tui";
4
+ import {
5
+ type GitRepositoryStatus,
6
+ readGitStatus,
7
+ repoAlias,
8
+ type SessionWorktree,
9
+ SessionWorktreeTracker,
10
+ } from "./worktrees.ts";
11
+
12
+ export interface StatuslineData {
13
+ model: string;
14
+ cwd: string;
15
+ cwdGit: GitRepositoryStatus | null;
16
+ contextTokens: number | null;
17
+ contextWindow: number;
18
+ worktrees: SessionWorktree[];
19
+ sessionId: string;
20
+ }
21
+
22
+ const BLUE = "\x1b[38;2;0;153;255m";
23
+ const ORANGE = "\x1b[38;2;255;176;85m";
24
+ const GREEN = "\x1b[38;2;0;175;80m";
25
+ const CYAN = "\x1b[38;2;86;182;194m";
26
+ const RED = "\x1b[38;2;255;85;85m";
27
+ const YELLOW = "\x1b[38;2;230;200;0m";
28
+ const WHITE = "\x1b[38;2;220;220;220m";
29
+ const MAGENTA = "\x1b[38;2;190;120;255m";
30
+ const DIM = "\x1b[2m";
31
+ const RESET = "\x1b[0m";
32
+
33
+ function styled(style: string, text: string): string {
34
+ return `${style}${text}${RESET}`;
35
+ }
36
+
37
+ function contextColor(contextTokens: number, contextWindow: number): string {
38
+ const percent = contextWindow > 0 ? Math.floor((contextTokens * 100) / contextWindow) : 0;
39
+ if (percent >= 90) return RED;
40
+ if (percent >= 70) return YELLOW;
41
+ if (percent >= 50) return ORANGE;
42
+ return GREEN;
43
+ }
44
+
45
+ export function formatTokenCount(tokens: number): string {
46
+ const safeTokens = Math.max(0, Math.round(tokens));
47
+ if (safeTokens >= 1_000_000) return `${(safeTokens / 1_000_000).toFixed(1)}m`;
48
+ if (safeTokens >= 1_000) {
49
+ const thousands = safeTokens / 1_000;
50
+ return `${thousands < 10 && !Number.isInteger(thousands) ? thousands.toFixed(1) : Math.round(thousands)}k`;
51
+ }
52
+ return `${safeTokens}`;
53
+ }
54
+
55
+ function renderRepository(name: string, status: GitRepositoryStatus, nameColor = WHITE): string {
56
+ let part = styled(nameColor, name);
57
+ part += styled(DIM, ":");
58
+ part += styled(CYAN, status.branch);
59
+ if (status.dirty) part += styled(YELLOW, "*");
60
+ if (status.behind > 0) part += ` ${styled(ORANGE, `⇣${status.behind}`)}`;
61
+ return part;
62
+ }
63
+
64
+ function renderWorktreeLine(worktrees: SessionWorktree[]): string {
65
+ const separator = styled(DIM, " | ");
66
+ const parts = worktrees.map((worktree) => {
67
+ let part = renderRepository(repoAlias(worktree.repo), worktree);
68
+ if (worktree.pr !== undefined) {
69
+ const state = worktree.prState?.toUpperCase();
70
+ const color = state === "OPEN" ? GREEN : state === "MERGED" ? MAGENTA : state === "CLOSED" ? RED : DIM;
71
+ part += ` ${styled(color, `#${worktree.pr}`)}`;
72
+ }
73
+ return part;
74
+ });
75
+ return `${styled(DIM, "⑂")} ${parts.join(separator)}`;
76
+ }
77
+
78
+ export function renderStatusline(data: StatuslineData, width: number): string[] {
79
+ const lineCount = data.worktrees.length > 0 ? 3 : 2;
80
+ if (width <= 0) return Array.from({ length: lineCount }, () => "");
81
+
82
+ const separator = styled(DIM, " | ");
83
+ const used =
84
+ data.contextTokens === null
85
+ ? styled(DIM, "?")
86
+ : styled(contextColor(data.contextTokens, data.contextWindow), formatTokenCount(data.contextTokens));
87
+ const context = `${used}${styled(DIM, "/")}${styled(WHITE, formatTokenCount(data.contextWindow))}`;
88
+ const cwd = data.cwdGit ? renderRepository(data.cwd, data.cwdGit) : styled(CYAN, data.cwd);
89
+ const summary = styled(BLUE, data.model) + separator + cwd + separator + context;
90
+
91
+ const lines = [truncateToWidth(summary, width)];
92
+ if (data.worktrees.length > 0) lines.push(truncateToWidth(renderWorktreeLine(data.worktrees), width, "…"));
93
+ lines.push(truncateToWidth(styled(DIM, data.sessionId), width));
94
+ return lines;
95
+ }
96
+
97
+ export default function statuslineExtension(pi: ExtensionAPI): void {
98
+ let requestRender: (() => void) | undefined;
99
+ let tracker: SessionWorktreeTracker | undefined;
100
+ let cwdGit: GitRepositoryStatus | null = null;
101
+ let cwdStatusAbort: AbortController | undefined;
102
+ let cwdStatusInFlight: Promise<void> | undefined;
103
+
104
+ const runInBackground = (operation: Promise<void>): void => {
105
+ operation.catch(() => {
106
+ // Statusline enrichment is best-effort and must never interrupt the agent.
107
+ });
108
+ };
109
+
110
+ const refreshCwdStatus = (ctx: ExtensionContext): Promise<void> => {
111
+ if (!cwdStatusAbort) return Promise.resolve();
112
+ if (cwdStatusInFlight) return cwdStatusInFlight.then(() => refreshCwdStatus(ctx));
113
+ const controller = cwdStatusAbort;
114
+ const refresh = (async () => {
115
+ const next = (await readGitStatus(
116
+ (command, args, options) => pi.exec(command, args, options),
117
+ ctx.cwd,
118
+ controller.signal,
119
+ )) ?? null;
120
+ if (controller.signal.aborted || cwdStatusAbort !== controller) return;
121
+ if (
122
+ cwdGit?.branch === next?.branch &&
123
+ cwdGit?.dirty === next?.dirty &&
124
+ cwdGit?.behind === next?.behind
125
+ ) {
126
+ return;
127
+ }
128
+ cwdGit = next;
129
+ requestRender?.();
130
+ })().finally(() => {
131
+ if (cwdStatusInFlight === refresh) cwdStatusInFlight = undefined;
132
+ });
133
+ cwdStatusInFlight = refresh;
134
+ return refresh;
135
+ };
136
+
137
+ const resetTracker = (ctx: ExtensionContext): void => {
138
+ tracker?.dispose();
139
+ cwdStatusAbort?.abort();
140
+ cwdGit = null;
141
+ cwdStatusAbort = new AbortController();
142
+ cwdStatusInFlight = undefined;
143
+ const next = new SessionWorktreeTracker({
144
+ exec: (command, args, options) => pi.exec(command, args, options),
145
+ home: process.env.HOME ?? "",
146
+ onChange: () => requestRender?.(),
147
+ });
148
+ tracker = next;
149
+ runInBackground(refreshCwdStatus(ctx));
150
+ runInBackground(next.seedFromEntries(ctx.sessionManager.getBranch()));
151
+ runInBackground(next.includeCurrentWorktree(ctx.cwd));
152
+ };
153
+
154
+ pi.on("session_start", (_event, ctx) => {
155
+ if (ctx.mode !== "tui") return;
156
+
157
+ ctx.ui.setFooter((tui, _theme, footerData) => {
158
+ requestRender = () => tui.requestRender();
159
+ const stopBranchUpdates = footerData.onBranchChange(() => {
160
+ runInBackground(refreshCwdStatus(ctx));
161
+ tui.requestRender();
162
+ });
163
+
164
+ return {
165
+ dispose(): void {
166
+ stopBranchUpdates();
167
+ requestRender = undefined;
168
+ },
169
+ invalidate(): void {},
170
+ render(width: number): string[] {
171
+ const usage = ctx.getContextUsage();
172
+ const cwd = basename(ctx.cwd) || ctx.cwd;
173
+ const model = ctx.model?.id.split("/").pop() || "no-model";
174
+
175
+ return renderStatusline(
176
+ {
177
+ model,
178
+ cwd,
179
+ cwdGit,
180
+ contextTokens: usage?.tokens ?? null,
181
+ contextWindow: usage?.contextWindow ?? ctx.model?.contextWindow ?? 0,
182
+ worktrees: tracker?.getWorktrees() ?? [],
183
+ sessionId: ctx.sessionManager.getSessionId(),
184
+ },
185
+ width,
186
+ );
187
+ },
188
+ };
189
+ });
190
+
191
+ resetTracker(ctx);
192
+ });
193
+
194
+ pi.on("tool_call", (event) => {
195
+ if (tracker) runInBackground(tracker.observeToolInput(event.toolName, event.input));
196
+ });
197
+ pi.on("turn_end", (_event, ctx) => {
198
+ requestRender?.();
199
+ runInBackground(refreshCwdStatus(ctx));
200
+ if (tracker) runInBackground(tracker.refresh());
201
+ });
202
+ pi.on("model_select", () => requestRender?.());
203
+ pi.on("session_tree", (_event, ctx) => resetTracker(ctx));
204
+ pi.on("session_shutdown", () => {
205
+ tracker?.dispose();
206
+ tracker = undefined;
207
+ cwdStatusAbort?.abort();
208
+ cwdStatusAbort = undefined;
209
+ cwdStatusInFlight = undefined;
210
+ cwdGit = null;
211
+ });
212
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@hank-warren/pi-statusline",
3
+ "version": "0.1.0",
4
+ "description": "Compact Pi footer statusline: model ID, git branch/dirty/behind state, linked worktrees with PR numbers, context usage, and session ID.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "statusline",
10
+ "footer",
11
+ "git",
12
+ "coding-agent"
13
+ ],
14
+ "author": "Hank Warren",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/hank-warren/pi-extensions.git",
19
+ "directory": "packages/pi-statusline"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/hank-warren/pi-extensions/issues"
23
+ },
24
+ "homepage": "https://github.com/hank-warren/pi-extensions/tree/main/packages/pi-statusline#readme",
25
+ "engines": {
26
+ "node": ">=18.0.0"
27
+ },
28
+ "pi": {
29
+ "extensions": [
30
+ "./index.ts"
31
+ ]
32
+ },
33
+ "files": [
34
+ "index.ts",
35
+ "worktrees.ts",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "peerDependencies": {
40
+ "@earendil-works/pi-coding-agent": "*",
41
+ "@earendil-works/pi-tui": "*"
42
+ }
43
+ }
package/worktrees.ts ADDED
@@ -0,0 +1,359 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { basename, join, resolve } from "node:path";
3
+ import type { ExecResult } from "@earendil-works/pi-coding-agent";
4
+
5
+ export const PR_TTL_MS = 5 * 60_000;
6
+ export const PR_ABSENT_TTL_MS = 5_000;
7
+
8
+ export interface GitRepositoryStatus {
9
+ branch: string;
10
+ dirty: boolean;
11
+ behind: number;
12
+ }
13
+
14
+ export interface SessionWorktree extends GitRepositoryStatus {
15
+ path: string;
16
+ repo: string;
17
+ pr?: number;
18
+ prState?: string;
19
+ }
20
+
21
+ interface CachedWorktree extends SessionWorktree {
22
+ prCheckedAt: number;
23
+ }
24
+
25
+ interface WorktreeMetadata {
26
+ path: string;
27
+ repo: string;
28
+ branch: string;
29
+ }
30
+
31
+ export interface WorktreeTrackerHost {
32
+ exec(
33
+ command: string,
34
+ args: string[],
35
+ options: { cwd?: string; timeout?: number; signal?: AbortSignal },
36
+ ): Promise<ExecResult>;
37
+ home: string;
38
+ now?: () => number;
39
+ onChange?: () => void;
40
+ }
41
+
42
+ export function parseGitStatus(output: string): GitRepositoryStatus | undefined {
43
+ let branch: string | undefined;
44
+ let oid: string | undefined;
45
+ let behind = 0;
46
+ let dirty = false;
47
+
48
+ for (const line of output.split(/\r?\n/)) {
49
+ if (line.startsWith("# branch.oid ")) oid = line.slice("# branch.oid ".length).trim();
50
+ else if (line.startsWith("# branch.head ")) branch = line.slice("# branch.head ".length).trim();
51
+ else if (line.startsWith("# branch.ab ")) {
52
+ const match = line.match(/^# branch\.ab \+\d+ -(\d+)$/);
53
+ if (match) behind = Number.parseInt(match[1] ?? "0", 10);
54
+ } else if (line.length > 0 && !line.startsWith("# ")) dirty = true;
55
+ }
56
+
57
+ if (branch === "(detached)") branch = oid && oid !== "(initial)" ? oid.slice(0, 8) : "detached";
58
+ if (!branch || branch === "(unknown)") return undefined;
59
+ return { branch, dirty, behind };
60
+ }
61
+
62
+ export async function readGitStatus(
63
+ exec: WorktreeTrackerHost["exec"],
64
+ path: string,
65
+ signal?: AbortSignal,
66
+ ): Promise<GitRepositoryStatus | undefined> {
67
+ const result = await exec(
68
+ "git",
69
+ [
70
+ "--no-optional-locks",
71
+ "-C",
72
+ path,
73
+ "status",
74
+ "--porcelain=v2",
75
+ "--branch",
76
+ "--untracked-files=normal",
77
+ "--no-renames",
78
+ ],
79
+ { timeout: 3_000, signal },
80
+ );
81
+ return result.code === 0 ? parseGitStatus(result.stdout) : undefined;
82
+ }
83
+
84
+ function visitStrings(value: unknown, visit: (value: string) => void): void {
85
+ if (typeof value === "string") {
86
+ visit(value);
87
+ return;
88
+ }
89
+ if (Array.isArray(value)) {
90
+ for (const item of value) visitStrings(item, visit);
91
+ return;
92
+ }
93
+ if (!value || typeof value !== "object") return;
94
+ for (const item of Object.values(value)) visitStrings(item, visit);
95
+ }
96
+
97
+ export function extractWorktreePaths(value: unknown, home: string): string[] {
98
+ const names = new Set<string>();
99
+ const needle = "repos/worktrees/";
100
+
101
+ visitStrings(value, (text) => {
102
+ let rest = text;
103
+ while (true) {
104
+ const index = rest.indexOf(needle);
105
+ if (index < 0) break;
106
+ rest = rest.slice(index + needle.length);
107
+ const name = rest.match(/^[a-zA-Z0-9._-]+/)?.[0];
108
+ if (name) names.add(name);
109
+ if (rest.length === 0) break;
110
+ rest = rest.slice(Math.max(1, name?.length ?? 0));
111
+ }
112
+ });
113
+
114
+ return [...names].sort().map((name) => join(home, "repos", "worktrees", name));
115
+ }
116
+
117
+ function stripHeredocBodies(command: string): string {
118
+ const visible: string[] = [];
119
+ const delimiters: Array<{ value: string; stripTabs: boolean }> = [];
120
+
121
+ for (const line of command.split(/\r?\n/)) {
122
+ const active = delimiters[0];
123
+ if (active) {
124
+ const candidate = active.stripTabs ? line.replace(/^\t+/, "") : line;
125
+ if (candidate === active.value) delimiters.shift();
126
+ continue;
127
+ }
128
+
129
+ visible.push(line);
130
+ const pattern = /(?<!<)<<(?!<)(-?)\s*(?:'([^']+)'|"([^"]+)"|\\?([^\s;&|<>]+))/g;
131
+ for (const match of line.matchAll(pattern)) {
132
+ const value = match[2] ?? match[3] ?? match[4];
133
+ if (value) delimiters.push({ value, stripTabs: match[1] === "-" });
134
+ }
135
+ }
136
+
137
+ return visible.join("\n");
138
+ }
139
+
140
+ export function extractWorktreePathsFromToolCall(toolName: string, input: unknown, home: string): string[] {
141
+ const baseName = toolName.slice(toolName.lastIndexOf(".") + 1);
142
+ if (baseName !== "bash" || !input || typeof input !== "object" || Array.isArray(input)) {
143
+ return extractWorktreePaths(input, home);
144
+ }
145
+
146
+ const command = (input as { command?: unknown }).command;
147
+ return typeof command === "string" ? extractWorktreePaths(stripHeredocBodies(command), home) : [];
148
+ }
149
+
150
+ export function extractWorktreePathsFromEntries(entries: readonly unknown[], home: string): string[] {
151
+ const paths = new Set<string>();
152
+
153
+ for (const entry of entries) {
154
+ if (!entry || typeof entry !== "object") continue;
155
+ const candidate = entry as { type?: unknown; message?: unknown };
156
+ if (candidate.type !== "message" || !candidate.message || typeof candidate.message !== "object") continue;
157
+ const message = candidate.message as { role?: unknown; content?: unknown };
158
+ if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
159
+
160
+ for (const block of message.content) {
161
+ if (!block || typeof block !== "object") continue;
162
+ const toolCall = block as { type?: unknown; name?: unknown; arguments?: unknown };
163
+ if (toolCall.type !== "toolCall" || typeof toolCall.name !== "string") continue;
164
+ for (const path of extractWorktreePathsFromToolCall(toolCall.name, toolCall.arguments, home)) paths.add(path);
165
+ }
166
+ }
167
+
168
+ return [...paths].sort();
169
+ }
170
+
171
+ export function repoAlias(repo: string): string {
172
+ switch (repo) {
173
+ case "model-runtime-engine":
174
+ return "mre";
175
+ case "frontend":
176
+ return "fe";
177
+ case "infrastructure":
178
+ return "infra";
179
+ case "platform-releases":
180
+ return "rel";
181
+ case "np-integration-testing":
182
+ return "tests";
183
+ default:
184
+ return repo.startsWith("platform-") ? repo.slice("platform-".length) : repo;
185
+ }
186
+ }
187
+
188
+ export async function readWorktreeMetadata(path: string): Promise<WorktreeMetadata | undefined> {
189
+ try {
190
+ const gitFile = await readFile(join(path, ".git"), "utf8");
191
+ const rawGitDir = gitFile.trim().match(/^gitdir:\s*(.+)$/)?.[1];
192
+ if (!rawGitDir) return undefined;
193
+ const gitDir = resolve(path, rawGitDir).replaceAll("\\", "/");
194
+ const marker = "/.git/worktrees/";
195
+ const markerIndex = gitDir.indexOf(marker);
196
+ if (markerIndex < 0) return undefined;
197
+
198
+ const head = (await readFile(join(gitDir, "HEAD"), "utf8")).trim();
199
+ const branch = head.startsWith("ref: refs/heads/") ? head.slice("ref: refs/heads/".length) : head.slice(0, 8);
200
+ if (!branch) return undefined;
201
+
202
+ return {
203
+ path,
204
+ repo: basename(gitDir.slice(0, markerIndex)),
205
+ branch,
206
+ };
207
+ } catch {
208
+ return undefined;
209
+ }
210
+ }
211
+
212
+ function sameDisplay(a: CachedWorktree | undefined, b: CachedWorktree): boolean {
213
+ return Boolean(
214
+ a &&
215
+ a.path === b.path &&
216
+ a.repo === b.repo &&
217
+ a.branch === b.branch &&
218
+ a.dirty === b.dirty &&
219
+ a.behind === b.behind &&
220
+ a.pr === b.pr &&
221
+ a.prState === b.prState,
222
+ );
223
+ }
224
+
225
+ export class SessionWorktreeTracker {
226
+ private readonly trackedPaths = new Set<string>();
227
+ private readonly cache = new Map<string, CachedWorktree>();
228
+ private readonly inFlight = new Map<string, Promise<void>>();
229
+ private readonly abortController = new AbortController();
230
+ private disposed = false;
231
+
232
+ constructor(private readonly host: WorktreeTrackerHost) {}
233
+
234
+ getWorktrees(): SessionWorktree[] {
235
+ return [...this.cache.values()]
236
+ .filter((worktree) => {
237
+ const state = worktree.prState?.toUpperCase();
238
+ return state !== "MERGED" && state !== "CLOSED";
239
+ })
240
+ .map(({ prCheckedAt: _prCheckedAt, ...worktree }) => worktree)
241
+ .sort((a, b) => a.path.localeCompare(b.path));
242
+ }
243
+
244
+ async seedFromEntries(entries: readonly unknown[]): Promise<void> {
245
+ for (const path of extractWorktreePathsFromEntries(entries, this.host.home)) this.trackedPaths.add(path);
246
+ await this.refresh();
247
+ }
248
+
249
+ async observeToolInput(toolName: string, input: unknown): Promise<void> {
250
+ const added: string[] = [];
251
+ for (const path of extractWorktreePathsFromToolCall(toolName, input, this.host.home)) {
252
+ if (this.trackedPaths.has(path)) continue;
253
+ this.trackedPaths.add(path);
254
+ added.push(path);
255
+ }
256
+ await Promise.all(added.map((path) => this.refreshPath(path)));
257
+ }
258
+
259
+ async includeCurrentWorktree(cwd: string): Promise<void> {
260
+ const result = await this.host.exec(
261
+ "git",
262
+ ["--no-optional-locks", "-C", cwd, "rev-parse", "--show-toplevel", "--absolute-git-dir"],
263
+ { timeout: 2_000, signal: this.abortController.signal },
264
+ );
265
+ if (this.disposed || result.code !== 0) return;
266
+ const [topLevel, gitDir] = result.stdout.trim().split(/\r?\n/);
267
+ if (!topLevel || !gitDir?.replaceAll("\\", "/").includes("/.git/worktrees/")) return;
268
+ const path = resolve(topLevel);
269
+ if (!this.trackedPaths.has(path)) this.trackedPaths.add(path);
270
+ await this.refreshPath(path);
271
+ }
272
+
273
+ async refresh(): Promise<void> {
274
+ await Promise.all(
275
+ [...this.trackedPaths].map(async (path) => {
276
+ const existing = this.inFlight.get(path);
277
+ if (existing) await existing;
278
+ if (!this.disposed && this.trackedPaths.has(path)) await this.refreshPath(path);
279
+ }),
280
+ );
281
+ }
282
+
283
+ dispose(): void {
284
+ this.disposed = true;
285
+ this.abortController.abort();
286
+ this.trackedPaths.clear();
287
+ this.cache.clear();
288
+ }
289
+
290
+ private refreshPath(path: string): Promise<void> {
291
+ const existing = this.inFlight.get(path);
292
+ if (existing) return existing;
293
+
294
+ const refresh = this.refreshPathUncached(path).finally(() => {
295
+ if (this.inFlight.get(path) === refresh) this.inFlight.delete(path);
296
+ });
297
+ this.inFlight.set(path, refresh);
298
+ return refresh;
299
+ }
300
+
301
+ private async refreshPathUncached(path: string): Promise<void> {
302
+ const metadata = await readWorktreeMetadata(path);
303
+ if (this.disposed) return;
304
+ if (!metadata) {
305
+ const changed = this.cache.delete(path);
306
+ this.trackedPaths.delete(path);
307
+ if (changed) this.host.onChange?.();
308
+ return;
309
+ }
310
+
311
+ const previous = this.cache.get(path);
312
+ const status = await readGitStatus(
313
+ (command, args, options) => this.host.exec(command, args, options),
314
+ path,
315
+ this.abortController.signal,
316
+ );
317
+ if (this.disposed) return;
318
+ const branch = status?.branch ?? metadata.branch;
319
+ const branchChanged = previous !== undefined && previous.branch !== branch;
320
+ let next: CachedWorktree = {
321
+ ...metadata,
322
+ branch,
323
+ dirty: status?.dirty ?? (branchChanged ? false : (previous?.dirty ?? false)),
324
+ behind: status?.behind ?? (branchChanged ? 0 : (previous?.behind ?? 0)),
325
+ pr: branchChanged ? undefined : previous?.pr,
326
+ prState: branchChanged ? undefined : previous?.prState,
327
+ prCheckedAt: branchChanged ? 0 : (previous?.prCheckedAt ?? 0),
328
+ };
329
+ if (!sameDisplay(previous, next)) {
330
+ this.cache.set(path, next);
331
+ this.host.onChange?.();
332
+ }
333
+
334
+ const now = this.host.now?.() ?? Date.now();
335
+ const ttl = next.pr === undefined ? PR_ABSENT_TTL_MS : PR_TTL_MS;
336
+ if (now - next.prCheckedAt < ttl) return;
337
+
338
+ const result = await this.host.exec("gh", ["pr", "view", "--json", "number,state"], {
339
+ cwd: path,
340
+ timeout: 8_000,
341
+ signal: this.abortController.signal,
342
+ });
343
+ if (this.disposed) return;
344
+
345
+ if (result.code === 0) {
346
+ try {
347
+ const value = JSON.parse(result.stdout) as { number?: unknown; state?: unknown };
348
+ if (typeof value.number === "number") next = { ...next, pr: value.number };
349
+ if (typeof value.state === "string") next = { ...next, prState: value.state };
350
+ } catch {
351
+ // Keep the last known PR when gh returns malformed output.
352
+ }
353
+ }
354
+ next = { ...next, prCheckedAt: now };
355
+ const changed = !sameDisplay(this.cache.get(path), next);
356
+ this.cache.set(path, next);
357
+ if (changed) this.host.onChange?.();
358
+ }
359
+ }