pi-subagents-lite 1.0.2 → 1.2.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.
@@ -4,20 +4,23 @@
4
4
 
5
5
  import { truncateToWidth } from "@earendil-works/pi-tui";
6
6
  import type { AgentManager } from "../agent-manager.js";
7
- import { getConfig } from "../agent-types.js";
8
- import type { AgentRecord, SubagentType } from "../types.js";
7
+ import type { AgentRecord, Theme } from "../types.js";
9
8
  import {
10
- formatTokens,
9
+ formatCost,
11
10
  getLifetimeTotal,
12
11
  getSessionContextPercent,
13
12
  type LifetimeUsage,
14
13
  type SessionLike,
15
14
  } from "../usage.js";
15
+ import { formatMs, buildStatsParts, getDisplayName } from "../format.js";
16
+
17
+ // Re-export Theme so existing consumers (model-selector, result-viewer) don't break
18
+ export type { Theme } from "../types.js";
16
19
 
17
20
  // ---- Constants ----
18
21
 
19
22
  /** Maximum number of rendered lines before overflow collapse kicks in. */
20
- const MAX_WIDGET_LINES = 12;
23
+ const DEFAULT_MAX_WIDGET_LINES = 12;
21
24
 
22
25
  /** Braille spinner frames for animated running indicator. */
23
26
  const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -58,11 +61,6 @@ const TOOL_DISPLAY: Record<string, string> = {
58
61
 
59
62
  // ---- Types ----
60
63
 
61
- export type Theme = {
62
- fg(color: string, text: string): string;
63
- bold(text: string): string;
64
- italic?: (text: string) => string;
65
- };
66
64
 
67
65
  export type UICtx = {
68
66
  setStatus(key: string, text: string | undefined): void;
@@ -99,95 +97,10 @@ export interface AgentActivity {
99
97
  lifetimeUsage: LifetimeUsage;
100
98
  }
101
99
 
100
+ // ---- Re-exports from format.ts (backward compatibility) ----
101
+ export { formatMs, buildStatsParts, getDisplayName } from "../format.js";
102
102
 
103
-
104
- // ---- Formatting helpers ----
105
-
106
- /**
107
- * Token count with optional context-fill % and compaction-count annotations.
108
- * Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
109
- * Compaction count rendered as `↻ N` in dim.
110
- *
111
- * "12.3k" — no annotations
112
- * "12.3k(45%)" — percent only
113
- * "12.3k(↻ 2)" — compactions only (e.g. right after compact)
114
- * "12.3k(45%·↻ 2)" — both
115
- */
116
- function formatSessionTokens(
117
- tokens: number,
118
- percent: number | null,
119
- theme: Theme,
120
- compactions = 0,
121
- ): string {
122
- const tokenStr = formatTokens(tokens);
123
- const annot: string[] = [];
124
- if (percent !== null) {
125
- const color = percent >= 85 ? "error" : percent >= 70 ? "warning" : "dim";
126
- annot.push(theme.fg(color, `${Math.round(percent)}%`));
127
- }
128
- if (compactions > 0) {
129
- annot.push(theme.fg("dim", `↻ ${compactions}`));
130
- }
131
- if (annot.length === 0) return tokenStr;
132
- // Include closing paren in the last annotation's color span to prevent
133
- // ANSI reset from leaving `)` in default color when wrapped in outer dim.
134
- const lastIdx = annot.length - 1;
135
- annot[lastIdx] += ")";
136
- return `${tokenStr}(${annot.join("·")}`;
137
- }
138
-
139
- /** Format turn count with optional max limit: "5≤30⟳" or "5⟳". */
140
- function formatTurns(turnCount: number, maxTurns?: number | null): string {
141
- return maxTurns != null ? `${turnCount}≤${maxTurns}⟳ ` : `${turnCount}⟳ `;
142
- }
143
-
144
- /** Format milliseconds as a compact human-readable duration: "1h 1m 1s", "5m 37s", "10s", "<1s". */
145
- export function formatMs(ms: number): string {
146
- if (!Number.isFinite(ms) || ms < 1000) return "<1s";
147
-
148
- const totalSeconds = Math.floor(ms / 1000);
149
- const hours = Math.floor(totalSeconds / 3600);
150
- const minutes = Math.floor((totalSeconds % 3600) / 60);
151
- const seconds = totalSeconds % 60;
152
-
153
- const parts: string[] = [];
154
- if (hours > 0) parts.push(`${hours}h`);
155
- if (minutes > 0) parts.push(`${minutes}m`);
156
- if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`);
157
-
158
- return parts.join(" ");
159
- }
160
-
161
- /**
162
- * Build common stats parts: toolUses · turns · tokens with context %.
163
- * Shared by AgentWidget and index.ts for consistent stats display.
164
- */
165
- export function buildStatsParts(
166
- args: {
167
- toolUses: number;
168
- turnCount?: number;
169
- maxTurns?: number;
170
- tokens: number;
171
- contextPercent: number | null;
172
- compactions: number;
173
- },
174
- theme: Theme,
175
- ): string[] {
176
- const parts: string[] = [];
177
- if (args.toolUses > 0) parts.push(`${args.toolUses}🛠 `);
178
- if (args.turnCount != null) parts.push(formatTurns(args.turnCount, args.maxTurns));
179
- if (args.tokens > 0) {
180
- parts.push(formatSessionTokens(
181
- args.tokens, args.contextPercent, theme, args.compactions,
182
- ));
183
- }
184
- return parts;
185
- }
186
-
187
- /** Get display name for any agent type (built-in or custom). */
188
- export function getDisplayName(type: SubagentType): string {
189
- return getConfig(type).displayName;
190
- }
103
+ // ---- Widget-internal helpers ----
191
104
 
192
105
  /**
193
106
  * Wrap a stats line in dim ANSI codes, re-applying dim after any inner
@@ -245,6 +158,8 @@ export class AgentWidget {
245
158
  /** Finished agents: agent ID → turns since finished. */
246
159
  private finishedTurnAge = new Map<string, number>();
247
160
 
161
+ /** Whether to show cost in stats and status bar. */
162
+ private showCost = false;
248
163
 
249
164
  /** Whether the widget callback is currently registered with the TUI. */
250
165
  private widgetRegistered = false;
@@ -252,12 +167,67 @@ export class AgentWidget {
252
167
  private tui: TUI | undefined;
253
168
  /** Last status bar text, used to avoid redundant setStatus calls. */
254
169
  private lastStatusText: string | undefined;
170
+ /** Pending tool expansion state from onTerminalInput (push-based, no polling). */
171
+ private pendingToolsExpanded: boolean | undefined;
172
+
173
+ /** Whether to use compact mode (1-line per agent). */
174
+ private compactMode = false;
175
+
176
+ /** Whether "force compact" mode is ON — overrides ctrl+o shortcut. */
177
+ private forceCompact = false;
178
+
179
+ /** Whether ctrl+o shortcut is enabled (syncs compact with toolsExpanded). */
180
+ private widgetShortcut = false;
181
+
182
+ /** Maximum lines for full mode. */
183
+ private maxLines = DEFAULT_MAX_WIDGET_LINES;
184
+
185
+ /** Maximum lines for compact mode. */
186
+ private maxLinesCompact = Math.floor(DEFAULT_MAX_WIDGET_LINES / 2);
255
187
 
256
188
  constructor(
257
189
  private manager: AgentManager,
258
190
  private agentActivity: Map<string, AgentActivity>,
259
191
  ) {}
260
192
 
193
+ /** Set whether to show cost in stats and status bar. */
194
+ setShowCost(enabled: boolean) {
195
+ this.showCost = enabled;
196
+ }
197
+
198
+ /** Set compact mode (internal, for sync from ctrl+o). */
199
+ setCompactMode(enabled: boolean) {
200
+ if (this.compactMode === enabled) return;
201
+ this.compactMode = enabled;
202
+ this.update();
203
+ }
204
+
205
+ /** Set force compact mode — overrides ctrl+o shortcut. */
206
+ setForceCompact(enabled: boolean) {
207
+ this.forceCompact = enabled;
208
+ }
209
+
210
+ /** Set whether ctrl+o shortcut is enabled. */
211
+ setWidgetShortcut(enabled: boolean) {
212
+ this.widgetShortcut = enabled;
213
+ }
214
+
215
+ /** Notify widget that tool expansion state changed (push-based, no polling). */
216
+ notifyToolsExpansionChanged(expanded: boolean) {
217
+ this.pendingToolsExpanded = expanded;
218
+ this.update();
219
+ }
220
+
221
+ /** Set max lines for full mode. */
222
+ setMaxLines(lines: number) {
223
+ this.maxLines = lines;
224
+ }
225
+
226
+ /** Set max lines for compact mode. */
227
+ setMaxLinesCompact(lines: number) {
228
+ this.maxLinesCompact = lines;
229
+ }
230
+
261
231
  /** Set the UI context (grabbed from first tool execution). */
262
232
  setUICtx(ctx: UICtx) {
263
233
  if (ctx !== this.uiCtx) {
@@ -297,9 +267,9 @@ export class AgentWidget {
297
267
  const queued: AgentRecord[] = [];
298
268
  const finished: AgentRecord[] = [];
299
269
  for (const a of allAgents) {
300
- if (a.status === "running") running.push(a);
301
- else if (a.status === "queued") queued.push(a);
302
- else if (a.completedAt && this.shouldShowFinished(a.id, a.status)) finished.push(a);
270
+ if (a.lifecycle.status === "running") running.push(a);
271
+ else if (a.lifecycle.status === "queued") queued.push(a);
272
+ else if (a.lifecycle.completedAt && this.shouldShowFinished(a.id, a.lifecycle.status)) finished.push(a);
303
273
  }
304
274
  return { running, queued, finished };
305
275
  }
@@ -342,47 +312,44 @@ export class AgentWidget {
342
312
  }
343
313
 
344
314
  /** Render a finished agent line. */
345
- private renderFinishedLine(a: {
346
- id: string; type: SubagentType; status: string; description: string;
347
- toolUses: number; startedAt: number; completedAt?: number; error?: string;
348
- compactionCount: number; lifetimeUsage: LifetimeUsage;
349
- turnCount?: number; maxTurns?: number; session?: SessionLike;
350
- outputFile?: string;
351
- }, theme: Theme): string {
352
- const name = getDisplayName(a.type);
353
- const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt);
354
- const { icon, statusText } = this.finishedIconAndStatus(a.status, a.error, theme);
315
+ private renderFinishedLine(a: AgentRecord, theme: Theme): string {
316
+ const name = getDisplayName(a.display.type);
317
+ const duration = formatMs((a.lifecycle.completedAt ?? Date.now()) - a.lifecycle.startedAt);
318
+ const { icon, statusText } = this.finishedIconAndStatus(a.lifecycle.status, a.error, theme);
355
319
 
356
320
  const activity = this.agentActivity.get(a.id);
321
+ const usage = activity?.lifetimeUsage ?? a.stats.lifetimeUsage;
357
322
  const statsParts = buildStatsParts({
358
- toolUses: a.toolUses,
359
- turnCount: activity?.turnCount ?? a.turnCount,
360
- maxTurns: activity?.maxTurns ?? a.maxTurns,
361
- tokens: getLifetimeTotal(activity?.lifetimeUsage ?? a.lifetimeUsage),
362
- contextPercent: getSessionContextPercent(activity?.session ?? a.session),
363
- compactions: a.compactionCount,
323
+ toolUses: a.stats.toolUses,
324
+ turnCount: activity?.turnCount ?? a.stats.turnCount,
325
+ maxTurns: activity?.maxTurns ?? a.stats.maxTurns,
326
+ tokens: getLifetimeTotal(usage),
327
+ contextPercent: activity?.session ? getSessionContextPercent(activity.session) : a.stats.contextPercent ?? null,
328
+ compactions: a.stats.compactionCount,
329
+ cost: this.showCost ? usage.cost : undefined,
364
330
  }, theme);
365
331
  statsParts.push(duration);
366
332
 
367
333
  const statsLine = statsParts.join("·");
368
- return `${icon} ${theme.fg("dim", name)} ${theme.fg("dim", a.description)} ${wrapInDim(theme, statsLine)}${statusText}`;
334
+ return `${icon} ${theme.fg("dim", name)} ${theme.fg("dim", a.display.description)} ${wrapInDim(theme, statsLine)}${statusText}`;
369
335
  }
370
336
 
371
- /** Build the stats line (toolUses · turns · tokens · elapsed) for a running agent. */
337
+ /** Build the stats line (toolUses · turns · tokens · cost · elapsed) for a running agent. */
372
338
  private buildStatsLine(
373
- agent: { toolUses: number; compactionCount: number; startedAt: number },
339
+ agent: AgentRecord,
374
340
  activity: AgentActivity | undefined,
375
341
  theme: Theme,
376
342
  ): string {
377
343
  const parts = buildStatsParts({
378
- toolUses: activity?.toolUses ?? agent.toolUses,
344
+ toolUses: activity?.toolUses ?? agent.stats.toolUses,
379
345
  turnCount: activity?.turnCount,
380
346
  maxTurns: activity?.maxTurns,
381
347
  tokens: getLifetimeTotal(activity?.lifetimeUsage),
382
348
  contextPercent: getSessionContextPercent(activity?.session),
383
- compactions: agent.compactionCount,
349
+ compactions: agent.stats.compactionCount,
350
+ cost: this.showCost ? activity?.lifetimeUsage?.cost : undefined,
384
351
  }, theme);
385
- parts.push(formatMs(Date.now() - agent.startedAt));
352
+ parts.push(formatMs(Date.now() - agent.lifecycle.startedAt));
386
353
  return parts.join("·");
387
354
  }
388
355
 
@@ -395,11 +362,18 @@ export class AgentWidget {
395
362
  const truncate = (line: string) => truncateToWidth(line, w);
396
363
  const blocks: RenderBlock[] = [];
397
364
  for (const a of finished) {
365
+ const continuations: string[] = [];
366
+ if (!this.isCompact()) {
367
+ if (a.display.outputFile || a.display.worktreeLabel) {
368
+ const parts: string[] = [];
369
+ if (a.display.worktreeLabel) parts.push(`@${a.display.worktreeLabel}`);
370
+ if (a.display.outputFile) parts.push(`tail -f ${a.display.outputFile}`);
371
+ continuations.push(truncate(theme.fg("dim", `${VLINE} ${parts.join(" ")}`)));
372
+ }
373
+ }
398
374
  blocks.push({
399
375
  header: truncate(`${theme.fg("dim", BRANCH)} ${this.renderFinishedLine(a, theme)}`),
400
- continuations: a.outputFile
401
- ? [truncate(theme.fg("dim", `${VLINE} tail -f ${a.outputFile}`))]
402
- : [],
376
+ continuations,
403
377
  });
404
378
  }
405
379
  return blocks;
@@ -415,21 +389,35 @@ export class AgentWidget {
415
389
  const truncate = (line: string) => truncateToWidth(line, w);
416
390
  const blocks: RenderBlock[] = [];
417
391
  for (const a of running) {
418
- const name = getDisplayName(a.type);
392
+ const name = getDisplayName(a.display.type);
419
393
  const bg = this.agentActivity.get(a.id);
420
394
  const statsLine = this.buildStatsLine(a, bg, theme);
421
395
  const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : THINKING_TEXT;
422
396
 
423
- const headerLine = `${BRANCH} ${theme.fg("accent", frame)} ${theme.bold(name)} ${a.description} ${statsLine}`;
424
- blocks.push({
425
- header: truncate(headerLine),
426
- continuations: [
427
- ...(a.outputFile
428
- ? [truncate(`${VLINE} ` + theme.fg("dim", `${VLINE} tail -f ${a.outputFile}`))]
429
- : []),
430
- truncate(`${VLINE} ` + theme.fg("dim", `└ ${activity}`)),
431
- ],
432
- });
397
+ if (this.isCompact()) {
398
+ // Compact: single line with activity inline, truncated description
399
+ const desc = a.display.description.length > 30 ? a.display.description.slice(0, 27) + "..." : a.display.description;
400
+ const headerLine = `${BRANCH} ${theme.fg("accent", frame)} ${theme.bold(name)} ${desc} ${statsLine} ${theme.fg("dim", activity)}`;
401
+ blocks.push({
402
+ header: truncate(headerLine),
403
+ continuations: [],
404
+ });
405
+ } else {
406
+ // Full: header + continuation lines
407
+ const headerLine = `${BRANCH} ${theme.fg("accent", frame)} ${theme.bold(name)} ${a.display.description} ${statsLine}`;
408
+ const continuations: string[] = [];
409
+ if (a.display.outputFile || a.display.worktreeLabel) {
410
+ const parts: string[] = [];
411
+ if (a.display.worktreeLabel) parts.push(`@${a.display.worktreeLabel}`);
412
+ if (a.display.outputFile) parts.push(`tail -f ${a.display.outputFile}`);
413
+ continuations.push(truncate(`${VLINE} ` + theme.fg("dim", `${VLINE} ${parts.join(" ")}`)));
414
+ }
415
+ continuations.push(truncate(`${VLINE} ` + theme.fg("dim", `└ ${activity}`)));
416
+ blocks.push({
417
+ header: truncate(headerLine),
418
+ continuations,
419
+ });
420
+ }
433
421
  }
434
422
  return blocks;
435
423
  }
@@ -455,6 +443,11 @@ export class AgentWidget {
455
443
  * connectors in a single pass. Last visible block gets CORNER + spaces, all others
456
444
  * keep BRANCH + VLINE.
457
445
  */
446
+ /** Whether the widget should render in compact mode. */
447
+ private isCompact(): boolean {
448
+ return this.forceCompact || (this.widgetShortcut && this.compactMode);
449
+ }
450
+
458
451
  private renderWidget(tui: TUI, theme: Theme): string[] {
459
452
  const { running, queued, finished } = this.categorizeAgents();
460
453
 
@@ -485,7 +478,8 @@ export class AgentWidget {
485
478
 
486
479
  // ---- Overflow logic (works with blocks, not lines) ----
487
480
 
488
- const maxBody = MAX_WIDGET_LINES - 1; // heading takes 1 line
481
+ const maxBodyLines = this.isCompact() ? this.maxLinesCompact : this.maxLines;
482
+ const maxBody = maxBodyLines - 1; // heading takes 1 line
489
483
  const totalBody = blocks.reduce((sum, b) => sum + 1 + b.continuations.length, 0);
490
484
 
491
485
  const heading = `${theme.fg(headingColor, headingIcon)} ${theme.fg(headingColor, "Agents")}`;
@@ -601,21 +595,40 @@ export class AgentWidget {
601
595
  }
602
596
 
603
597
  /** Update the status bar text, only if it changed. */
604
- private updateStatusBar(runningCount: number, queuedCount: number) {
605
- const statusParts: string[] = [];
606
- if (runningCount > 0) statusParts.push(`${runningCount} running`);
607
- if (queuedCount > 0) statusParts.push(`${queuedCount} queued`);
598
+ private updateStatusBar(runningCount: number, queuedCount: number, running: AgentRecord[]) {
608
599
  const total = runningCount + queuedCount;
609
- const newStatusText = `${statusParts.join(", ")} agent${total === 1 ? "" : "s"}`;
610
- if (newStatusText !== this.lastStatusText) {
611
- this.uiCtx?.setStatus(STATUS_KEY, newStatusText);
612
- this.lastStatusText = newStatusText;
600
+ let statusText = total > 0 ? `${total} agent${total === 1 ? "" : "s"}` : `agents`;
601
+ if (this.showCost) {
602
+ const sessionCost = this.manager.getTotalAgentCost();
603
+ // Also include in-flight running agents (not yet completed, so not in accumulator)
604
+ const runningCost = running.reduce((sum, a) => sum + a.stats.lifetimeUsage.cost, 0);
605
+ const totalCost = sessionCost + runningCost;
606
+ if (totalCost > 0) statusText += `: ${formatCost(totalCost)}`;
607
+ }
608
+ if (statusText !== this.lastStatusText) {
609
+ this.uiCtx?.setStatus(STATUS_KEY, statusText);
610
+ this.lastStatusText = statusText;
613
611
  }
614
612
  }
615
613
 
616
614
  /** Force an immediate widget update. */
617
615
  update() {
616
+ if (!this.manager) {
617
+ // Widget lost its manager reference (e.g., after session shutdown)
618
+ clearInterval(this.widgetInterval);
619
+ this.widgetInterval = undefined;
620
+ return;
621
+ }
618
622
  if (!this.uiCtx) return;
623
+
624
+ // Sync compact mode with tool expansion state (ctrl+o)
625
+ // Tools expanded → widget full, tools collapsed → widget compact
626
+ // Note: sync is triggered by onTerminalInput detecting ctrl+o, not polling
627
+ if (this.widgetShortcut && !this.forceCompact && this.pendingToolsExpanded !== undefined) {
628
+ this.compactMode = !this.pendingToolsExpanded;
629
+ this.pendingToolsExpanded = undefined;
630
+ }
631
+
619
632
  const { running, queued, finished } = this.categorizeAgents();
620
633
 
621
634
  const hasActive = running.length > 0 || queued.length > 0;
@@ -628,7 +641,7 @@ export class AgentWidget {
628
641
  }
629
642
 
630
643
  // Status bar — only call setStatus when the text actually changes
631
- this.updateStatusBar(running.length, queued.length);
644
+ this.updateStatusBar(running.length, queued.length, running);
632
645
 
633
646
  this.widgetFrame++;
634
647
 
package/src/usage.ts CHANGED
@@ -36,6 +36,11 @@ export function formatTokens(count: number): string {
36
36
  return `${count}`;
37
37
  }
38
38
 
39
+ /** Format cost as a dollar amount: "$0.00", "$0.01", "$1.23". */
40
+ export function formatCost(cost: number): string {
41
+ return `$${cost.toFixed(2)}`;
42
+ }
43
+
39
44
  /**
40
45
  * Context-window utilization (0–100), or null when unavailable
41
46
  * (no model contextWindow, or post-compaction before the next response).
@@ -0,0 +1,199 @@
1
+ /**
2
+ * worktree-validator.ts — Validate, resolve, and label a worktree path.
3
+ *
4
+ * Pure async functions that validate a `worktree_path` value against the parent's
5
+ * git repository. Depends on `pi.exec` for git commands.
6
+ *
7
+ * Validation strategy: compare `git-common-dir` of the parent and target paths.
8
+ * If they share the same common dir, the target is a worktree of the parent's repo.
9
+ */
10
+
11
+ import * as path from "node:path";
12
+ import { existsSync, statSync, realpathSync } from "node:fs";
13
+
14
+ /** Timeout for git commands (ms). */
15
+ const GIT_EXEC_TIMEOUT_MS = 5000;
16
+
17
+ /** Specific error messages returned to the LLM for self-correction. */
18
+ export const WORKTREE_VALIDATION_ERRORS = {
19
+ PATH_DOES_NOT_EXIST: "worktree_path does not exist: the specified path was not found on disk",
20
+ NOT_A_DIRECTORY: "worktree_path is not a directory: the specified path exists but is not a directory",
21
+ PARENT_NOT_IN_GIT_REPO: "worktree_path validation failed: the parent session is not inside a git repository",
22
+ NOT_IN_GIT_REPO: "worktree_path is not inside a git repository",
23
+ DIFFERENT_REPO: "worktree_path is not a worktree of the parent's repository",
24
+ GIT_NOT_FOUND: "worktree_path validation failed: git executable not found on this host",
25
+ GIT_TIMEOUT: "worktree_path validation failed: git command timed out",
26
+ } as const;
27
+
28
+ /** Successful validation result. */
29
+ export interface WorktreeValidationSuccess {
30
+ ok: true;
31
+ /** Resolved absolute path (symlinks followed, relative resolved). Undefined when path is empty/omitted. */
32
+ resolvedPath?: string;
33
+ /** Worktree root directory. */
34
+ worktreeRoot?: string;
35
+ /** Short display label for the widget. */
36
+ label?: string;
37
+ }
38
+
39
+ /** Failed validation result. */
40
+ export interface WorktreeValidationFailure {
41
+ ok: false;
42
+ /** Human-readable error describing the specific failure reason. */
43
+ error: string;
44
+ }
45
+
46
+ export type WorktreeValidationResult = WorktreeValidationSuccess | WorktreeValidationFailure;
47
+
48
+ /**
49
+ * Minimal interface for the pi exec function — only what the validator needs.
50
+ */
51
+ interface PiExec {
52
+ exec(cmd: string, args: string[], opts?: { cwd?: string; timeout?: number }): Promise<{ code: number; stdout: string; stderr: string }>;
53
+ }
54
+
55
+ /**
56
+ * Run `git rev-parse --git-common-dir` and return the trimmed result.
57
+ * Returns a failure result if the command fails or git is unavailable.
58
+ */
59
+ async function getGitCommonDir(
60
+ pi: PiExec,
61
+ cwd: string,
62
+ notInRepoError: string,
63
+ ): Promise<{ ok: true; commonDir: string } | { ok: false; error: string }> {
64
+ try {
65
+ const result = await pi.exec("git", ["rev-parse", "--git-common-dir"], { cwd, timeout: GIT_EXEC_TIMEOUT_MS });
66
+ if (result.code !== 0) return { ok: false, error: notInRepoError };
67
+ const commonDir = result.stdout.trim();
68
+ if (!commonDir) return { ok: false, error: notInRepoError };
69
+ return { ok: true, commonDir };
70
+ } catch (err: unknown) {
71
+ const msg = String(err instanceof Error ? err.message : err);
72
+ if (msg.includes("ENOENT") || msg.includes("not found")) {
73
+ return { ok: false, error: WORKTREE_VALIDATION_ERRORS.GIT_NOT_FOUND };
74
+ }
75
+ return { ok: false, error: WORKTREE_VALIDATION_ERRORS.GIT_TIMEOUT };
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Validate a worktree path against the parent's git repository.
81
+ *
82
+ * Resolution order:
83
+ * 1. Empty/whitespace → treated as omitted (return ok with no path)
84
+ * 2. Resolve relative against parent cwd
85
+ * 3. Resolve symlinks (realpath)
86
+ * 4. Check exists + is directory
87
+ * 5. Get and compare git-common-dir for parent and target
88
+ * 6. Get worktree root via --show-toplevel
89
+ * 7. Normalize and compute display label
90
+ *
91
+ * @param pi - Minimal exec interface (pi.exec)
92
+ * @param worktreePath - The raw worktree_path value from the LLM
93
+ * @param parentCwd - The parent session's working directory
94
+ * @returns Validation result with resolved path + label, or error
95
+ */
96
+ export async function validateWorktreePath(
97
+ pi: PiExec,
98
+ worktreePath: string,
99
+ parentCwd: string,
100
+ ): Promise<WorktreeValidationResult> {
101
+ // Step 1: Empty / whitespace → treat as omitted
102
+ if (!worktreePath || worktreePath.trim() === "") {
103
+ return { ok: true };
104
+ }
105
+
106
+ // Step 2: Resolve relative paths against parent cwd
107
+ const resolved = path.isAbsolute(worktreePath)
108
+ ? worktreePath
109
+ : path.resolve(parentCwd, worktreePath);
110
+
111
+ // Step 3: Check existence
112
+ if (!existsSync(resolved)) {
113
+ return { ok: false, error: WORKTREE_VALIDATION_ERRORS.PATH_DOES_NOT_EXIST };
114
+ }
115
+
116
+ // Step 4: Check is directory (resolve symlinks first via stat)
117
+ let realPath: string;
118
+ try {
119
+ const stat = statSync(resolved);
120
+ if (!stat.isDirectory()) {
121
+ return { ok: false, error: WORKTREE_VALIDATION_ERRORS.NOT_A_DIRECTORY };
122
+ }
123
+ // Resolve symlinks — use realpathSync to get the canonical path
124
+ realPath = realpathSync(resolved);
125
+ } catch {
126
+ // stat failed — likely a broken symlink or permission issue
127
+ return { ok: false, error: WORKTREE_VALIDATION_ERRORS.PATH_DOES_NOT_EXIST };
128
+ }
129
+
130
+ // Step 5: Get and compare git-common-dir for parent and target
131
+ const parentResult = await getGitCommonDir(pi, parentCwd, WORKTREE_VALIDATION_ERRORS.PARENT_NOT_IN_GIT_REPO);
132
+ if (!parentResult.ok) return parentResult;
133
+
134
+ const targetResult = await getGitCommonDir(pi, realPath, WORKTREE_VALIDATION_ERRORS.NOT_IN_GIT_REPO);
135
+ if (!targetResult.ok) return targetResult;
136
+
137
+ // Compare common dirs — must share the same repo
138
+ const parentCommonAbs = path.isAbsolute(parentResult.commonDir)
139
+ ? parentResult.commonDir
140
+ : path.resolve(parentCwd, parentResult.commonDir);
141
+ const targetCommonAbs = path.isAbsolute(targetResult.commonDir)
142
+ ? targetResult.commonDir
143
+ : path.resolve(realPath, targetResult.commonDir);
144
+
145
+ if (parentCommonAbs !== targetCommonAbs) {
146
+ return { ok: false, error: WORKTREE_VALIDATION_ERRORS.DIFFERENT_REPO };
147
+ }
148
+
149
+ // Step 6: Get the worktree root via git rev-parse --show-toplevel
150
+ let worktreeRoot: string;
151
+ try {
152
+ const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd: realPath, timeout: GIT_EXEC_TIMEOUT_MS });
153
+ if (result.code !== 0) {
154
+ worktreeRoot = realPath;
155
+ } else {
156
+ const raw = result.stdout.trim();
157
+ worktreeRoot = raw ? (path.isAbsolute(raw) ? raw : path.resolve(realPath, raw)) : realPath;
158
+ }
159
+ } catch {
160
+ worktreeRoot = realPath;
161
+ }
162
+
163
+ // Step 7: Normalize and compute display label
164
+ const normalizedRealPath = realPath.replace(/\\/g, "/");
165
+ const normalizedRoot = worktreeRoot.replace(/\\/g, "/");
166
+ const label = computeLabel(normalizedRealPath, normalizedRoot);
167
+
168
+ return {
169
+ ok: true,
170
+ resolvedPath: normalizedRealPath,
171
+ worktreeRoot: normalizedRoot,
172
+ label,
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Compute a short display label for the worktree path.
178
+ *
179
+ * Rules:
180
+ * - Root of worktree → basename (e.g., "/wt/feature" → "feature")
181
+ * - Subdirectory → basename/relative (e.g., "/wt/feature/packages/web" → "feature/packages/web")
182
+ * - Always forward slashes regardless of host OS
183
+ */
184
+ export function computeLabel(resolvedPath: string, worktreeRoot: string): string {
185
+ // Normalize both paths to forward slashes for cross-platform comparison
186
+ const normalizedResolved = resolvedPath.replace(/\\/g, "/");
187
+ const normalizedRoot = worktreeRoot.replace(/\\/g, "/");
188
+
189
+ const rootBasename = normalizedRoot.split("/").filter(Boolean).pop() ?? "";
190
+
191
+ if (normalizedResolved === normalizedRoot) {
192
+ return rootBasename;
193
+ }
194
+
195
+ // Compute relative path using posix separator
196
+ const relative = path.posix.relative(normalizedRoot, normalizedResolved);
197
+
198
+ return `${rootBasename}/${relative}`;
199
+ }