pi-subagents-lite 1.1.0 → 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.
package/README.md CHANGED
@@ -25,6 +25,7 @@ Tool names like `Agent` and `StopAgent`, and parameter names like `prompt`, `des
25
25
  ## Features
26
26
 
27
27
  - **Two tools** — `Agent` (spawn) and `StopAgent` (stop)
28
+ - **Manual spawn** — spawn agents from the `/agents` menu without asking the LLM. Full control over model, thinking, turns, and background mode.
28
29
  - **Foreground & background** — block or fire-and-forget with auto-delivered results
29
30
  - **Custom agent types** — define via `.md` files with YAML frontmatter (tools, model, thinking, turn limits)
30
31
  - **Smart model resolution** — 6-level precedence: session → config → frontmatter → parent. Set once, forget
@@ -38,6 +39,7 @@ Tool names like `Agent` and `StopAgent`, and parameter names like `prompt`, `des
38
39
  - **Output logs** — human-readable, `tail -f` friendly
39
40
  - **Grace turns** — configurable grace turns after `max_turns` before hard abort
40
41
  - **Reload safety** — warns when active agents are killed by session reload
42
+ - **Worktree support** — `worktree_path` parameter runs agents in a git worktree with validated path, worktree agent discovery, and UI label
41
43
 
42
44
  ## Install
43
45
 
@@ -99,6 +101,7 @@ Stop a running agent at any time via /agents command
99
101
  | `description` | ✅ | Brief description for the LLM caller |
100
102
  | `agent` | | Type name — `general-purpose`, `Explore`, or any custom type you define (see [Custom Agent Types](#custom-agent-types)). The available values are **auto-populated** from `.md` files in your agent directories — drop a file, it appears in the enum. Set `hidden: true` in frontmatter to hide a type from this list (still callable by name). |
101
103
  | `run_in_background` | | Fire-and-forget; result delivered automatically when done |
104
+ | `worktree_path` | | Absolute path to a git worktree. Agent runs in that worktree's context, discovers agents from its `.pi/agents/` directory, and displays a worktree label in the widget and menus. Path is validated against the parent repo's git common dir. |
102
105
 
103
106
  > `model`, `max_turns`, and `thinking` are **not visible to the LLM** through tool introspection — the extension injects them at call time from agent config and frontmatter. `model` is resolved via the [Model Resolution](#model-resolution) chain; `max_turns`/`thinking` come from the agent's config. See [Custom Agent Types](#custom-agent-types) to set them.
104
107
 
@@ -282,12 +285,14 @@ The LLM never passes `model` — it's injected at call time via the `tool_call`
282
285
 
283
286
  ### `/agents`
284
287
 
285
- Management menu with five sections:
288
+ Management menu with four sections:
286
289
 
287
- - **Model settings** — global default, per-type overrides, force background mode, cost display toggle, grace turns
288
- - **Concurrency** — default limit, per-provider and per-model slots, reset to defaults
289
290
  - **Running agents** — list with status and description; per-agent actions: view snapshot, view result, view error, steer, stop; bulk stop all running
290
- - **Widget settings** — force compact mode, max lines (full/compact), ctrl+o shortcut
291
+ - **Spawn agent** — manually spawn an agent without asking the LLM. Pick a type, enter a prompt, configure options (model, thinking, max turns, grace turns, background), and spawn. Options are pre-filled from agent config and current settings. Spawn immediately or customize first.
292
+ - **Settings** — model, concurrency, and widget settings grouped together
293
+ - **Model settings** — global default, per-type overrides, force background mode, cost display toggle, grace turns
294
+ - **Concurrency** — default limit, per-provider and per-model slots, reset to defaults
295
+ - **Widget settings** — force compact mode, max lines (full/compact), ctrl+o shortcut
291
296
  - **Debug** — agent types, agent briefing (sends capabilities to the LLM)
292
297
 
293
298
  ## Interface
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents-lite",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Lightweight sub-agents for pi — spawn specialized agents with isolated sessions, tools, and models.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -94,6 +94,10 @@ export interface SpawnOptions {
94
94
  maxTurns?: number;
95
95
  thinkingLevel?: ThinkingLevel;
96
96
  isBackground?: boolean;
97
+ /** Resolved worktree path — forwarded as cwd to runAgent. */
98
+ worktreePath?: string;
99
+ /** Short display label for the worktree (set on record display after spawn). */
100
+ worktreeLabel?: string;
97
101
  /**
98
102
  * Model key for concurrency pool lookup (e.g. "llamacpp/4b_small").
99
103
  * When set, the agent is counted against that model's concurrency limit.
@@ -260,6 +264,8 @@ export class AgentManager {
260
264
  type,
261
265
  description: options.description,
262
266
  invocation: options.invocation,
267
+ worktreePath: options.worktreePath,
268
+ worktreeLabel: options.worktreeLabel,
263
269
  },
264
270
  execution: {
265
271
  abortController,
@@ -318,6 +324,7 @@ export class AgentManager {
318
324
  model: options.model,
319
325
  maxTurns: options.maxTurns,
320
326
  thinkingLevel: options.thinkingLevel,
327
+ cwd: options.worktreePath,
321
328
  graceTurns: options.graceTurns,
322
329
  signal: record.execution.abortController!.signal,
323
330
  ...this.createRecordCallbacks(record, options),
@@ -60,8 +60,13 @@ export function setAgentScanDirs(userDir: string, projectDir: string): void {
60
60
  /**
61
61
  * Scan the known agent directories and register any newly discovered agents
62
62
  * that aren't already in the registry. Returns the number of new agents added.
63
+ *
64
+ * @param worktreeDir - Optional absolute path to a worktree's `.pi/agents/` directory.
65
+ * When set, agents from this directory are also scanned and added to the registry.
66
+ * Worktree-local types use "project" source attribution and follow the same
67
+ * parsing and name-uniqueness rules as the parent's project scan.
63
68
  */
64
- export async function discoverNewAgents(): Promise<number> {
69
+ export async function discoverNewAgents(worktreeDir?: string): Promise<number> {
65
70
  const [userAgents, projectAgents] = await Promise.all([
66
71
  scanAgentFilesInDir(userAgentDir, "user"),
67
72
  scanAgentFilesInDir(projectAgentDir, "project"),
@@ -76,6 +81,21 @@ export async function discoverNewAgents(): Promise<number> {
76
81
  count++;
77
82
  }
78
83
  }
84
+
85
+ // Scan worktree-local agents (only when worktreeDir is provided)
86
+ if (worktreeDir) {
87
+ const worktreeAgents = await scanAgentFilesInDir(worktreeDir, "project");
88
+ // Use mergeAgents to convert AgentConfigFromMd to AgentConfig (applies fromMd
89
+ // and BASE_DEFAULTS), then add only names not already in the registry.
90
+ const wtMerged = mergeAgents(new Map(), [], worktreeAgents);
91
+ for (const [name, config] of wtMerged) {
92
+ if (!agents.has(name)) {
93
+ agents.set(name, config);
94
+ count++;
95
+ }
96
+ }
97
+ }
98
+
79
99
  return count;
80
100
  }
81
101
 
package/src/index.ts CHANGED
@@ -49,6 +49,7 @@ import {
49
49
  clearManager,
50
50
  setWidget,
51
51
  setPiInstance,
52
+ setSessionCtx,
52
53
  resetSessionOverrides,
53
54
  resetLastToolsExpanded,
54
55
  syncWidgetSettings,
@@ -170,7 +171,7 @@ function registerAgentTool(pi: ExtensionAPI): void {
170
171
  description: Type.String(),
171
172
  agent: agentParam,
172
173
  run_in_background: Type.Optional(Type.Boolean()),
173
-
174
+ worktree_path: Type.Optional(Type.String()),
174
175
  }),
175
176
  execute: executeAgentTool,
176
177
 
@@ -248,6 +249,7 @@ export default function (pi: ExtensionAPI) {
248
249
  let unregisterTerminalInput: (() => void) | undefined;
249
250
 
250
251
  pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
252
+ setSessionCtx(ctx);
251
253
  resetSessionOverrides();
252
254
  agentActivity.clear();
253
255
  resetLastToolsExpanded();
package/src/menus.ts CHANGED
@@ -6,22 +6,27 @@
6
6
  */
7
7
 
8
8
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
9
- import { getAgentConfig, getAvailableTypes, getAllTypes } from "./agent-types.js";
10
- import type { AgentRecord } from "./types.js";
9
+ import { getAgentConfig, getAvailableTypes, getAllTypes, resolveType, discoverNewAgents } from "./agent-types.js";
10
+ import type { AgentRecord, ThinkingLevel } from "./types.js";
11
11
  import { SHORT_ID_LENGTH, CONFIG_AGENT_NON_MODEL_KEYS } from "./types.js";
12
+ import type { SpawnOptions } from "./agent-manager.js";
12
13
  import { ModelSelectorDialog, type ModelOption } from "./model-selector.js";
13
14
  import { ResultViewer, type ResultViewerStats } from "./result-viewer.js";
14
15
  import { getDisplayName } from "./format.js";
15
16
  import { buildSnapshotMarkdown } from "./context.js";
16
17
 
17
- import { parseModelKey } from "./utils.js";
18
+ import { parseModelKey, findModelInRegistry } from "./utils.js";
18
19
  import {
19
20
  __config,
20
21
  sessionOverrides,
21
22
  piInstance,
23
+ sessionCtx,
24
+ agentActivity,
22
25
  getManager,
26
+ getWidget,
23
27
  } from "./state.js";
24
28
  import { resolveModel } from "./model-precedence.js";
29
+ import { createActivityTracker, backgroundAgentIds } from "./tool-execution.js";
25
30
  import {
26
31
  setModelOverride,
27
32
  setDefaultModel,
@@ -242,6 +247,96 @@ async function runMenuLoop(
242
247
  }
243
248
  }
244
249
 
250
+ // ============================================================================
251
+ // Worktree picker helpers
252
+ // ============================================================================
253
+
254
+ /** Timeout for git worktree list command (ms). */
255
+ const WORKTREE_LIST_TIMEOUT_MS = 5000;
256
+
257
+ /** Max display length for a worktree path before truncation. */
258
+ const WORKTREE_PATH_TRUNCATE_LEN = 60;
259
+
260
+ interface WorktreeEntry {
261
+ path: string;
262
+ branch: string | null;
263
+ isDetached: boolean;
264
+ }
265
+
266
+ /**
267
+ * Parse `git worktree list --porcelain` output into structured entries.
268
+ *
269
+ * Format (one block per worktree, separated by blank lines):
270
+ * worktree /path/to/worktree
271
+ * HEAD <sha>
272
+ * branch refs/heads/<name> (or: (detached))
273
+ */
274
+ function parseWorktreeList(output: string): WorktreeEntry[] {
275
+ const entries: WorktreeEntry[] = [];
276
+ const blocks = output.split(/\n\n+/);
277
+ for (const block of blocks) {
278
+ if (!block.trim()) continue;
279
+ const lines = block.split("\n");
280
+ let path = "";
281
+ let branch: string | null = null;
282
+ let isDetached = false;
283
+ for (const line of lines) {
284
+ if (line.startsWith("worktree ")) {
285
+ path = line.slice("worktree ".length);
286
+ } else if (line.startsWith("branch refs/heads/")) {
287
+ branch = line.slice("branch refs/heads/".length);
288
+ } else if (line === "detached") {
289
+ isDetached = true;
290
+ }
291
+ }
292
+ if (path) {
293
+ entries.push({ path, branch, isDetached });
294
+ }
295
+ }
296
+ return entries;
297
+ }
298
+
299
+ /** Truncate a path for display, keeping the tail. */
300
+ function truncatePath(p: string): string {
301
+ if (p.length <= WORKTREE_PATH_TRUNCATE_LEN) return p;
302
+ return "..." + p.slice(p.length - WORKTREE_PATH_TRUNCATE_LEN + 3);
303
+ }
304
+
305
+ /**
306
+ * Fetch worktrees via `git worktree list --porcelain`.
307
+ * Returns null if git is unavailable or the command fails.
308
+ */
309
+ async function listWorktrees(cwd: string): Promise<WorktreeEntry[] | null> {
310
+ try {
311
+ const result = await piInstance.exec(
312
+ "git",
313
+ ["worktree", "list", "--porcelain"],
314
+ { cwd, timeout: WORKTREE_LIST_TIMEOUT_MS },
315
+ );
316
+ if (result.code !== 0) return null;
317
+ return parseWorktreeList(result.stdout);
318
+ } catch {
319
+ return null;
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Check whether a directory is inside a git repository.
325
+ * Uses `git rev-parse --git-common-dir` — the same strategy as the worktree validator.
326
+ */
327
+ async function isInGitRepo(cwd: string): Promise<boolean> {
328
+ try {
329
+ const result = await piInstance.exec(
330
+ "git",
331
+ ["rev-parse", "--git-common-dir"],
332
+ { cwd, timeout: WORKTREE_LIST_TIMEOUT_MS },
333
+ );
334
+ return result.code === 0 && result.stdout.trim() !== "";
335
+ } catch {
336
+ return false;
337
+ }
338
+ }
339
+
245
340
  // ============================================================================
246
341
  // /agents command handler
247
342
  // ============================================================================
@@ -439,26 +534,318 @@ function matchMenuChoice(
439
534
  return handlers[key];
440
535
  }
441
536
 
537
+ // ============================================================================
538
+ // Spawn agent menu
539
+ // ============================================================================
540
+
541
+ const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
542
+
543
+ /**
544
+ * Show the spawn agent flow: type selection → prompt → options sub-menu → spawn.
545
+ * Escape at any step aborts the flow and returns to the main menu.
546
+ */
547
+ export async function showSpawnAgentMenu(
548
+ ctx: ExtensionCommandContext,
549
+ modelOptions: string[],
550
+ ): Promise<void> {
551
+ // Step 1: Type selection loop (unknown type → error → retry)
552
+ let selectedType: string;
553
+ while (true) {
554
+ const types = getAvailableTypes();
555
+ if (types.length === 0) {
556
+ ctx.ui.notify("No agent types available", "error");
557
+ return;
558
+ }
559
+ const type = await ctx.ui.select("Select agent type", types);
560
+ if (type === undefined) return; // Escape → main menu
561
+
562
+ const config = getAgentConfig(type);
563
+ if (!config) {
564
+ ctx.ui.notify(`Unknown agent type: ${type}`, "error");
565
+ continue; // Loop back to type selection
566
+ }
567
+ selectedType = type;
568
+ break;
569
+ }
570
+
571
+ const agentConfig = getAgentConfig(selectedType)!;
572
+
573
+ // Step 2: Prompt entry loop (empty prompt → error → retry)
574
+ let prompt: string;
575
+ while (true) {
576
+ const input = await ctx.ui.input("Agent prompt");
577
+ if (input === undefined) return; // Escape → main menu
578
+
579
+ if (!input.trim()) {
580
+ ctx.ui.notify("Prompt cannot be empty", "error");
581
+ continue; // Loop back to prompt input
582
+ }
583
+ prompt = input.trim();
584
+ break;
585
+ }
586
+
587
+ // Step 3: Options sub-menu with spawn action
588
+ const autoDescription = prompt.length > 50 ? prompt.slice(0, 50) : prompt;
589
+ let description = autoDescription;
590
+
591
+ // Check if parent's cwd is inside a git repo (for worktree picker visibility)
592
+ const parentCwd = sessionCtx?.cwd ?? "";
593
+ const inGitRepo = parentCwd ? await isInGitRepo(parentCwd) : false;
594
+
595
+ // Worktree picker state
596
+ let currentWorktreePath: string | undefined;
597
+ let currentWorktreeLabel = "Inherits parent cwd";
598
+
599
+ // Pre-fill model from precedence chain
600
+ const parentModelId = sessionCtx?.model
601
+ ? `${sessionCtx.model.provider}/${sessionCtx.model.id}`
602
+ : "";
603
+ const effectiveModelStr = resolveModel({
604
+ subagentType: selectedType,
605
+ agentConfig,
606
+ config: __config,
607
+ parentModelId,
608
+ sessionOverrides,
609
+ });
610
+ let currentModelStr = effectiveModelStr || ""; // "" means inherit parent
611
+ let currentThinking: ThinkingLevel | undefined = agentConfig.thinking;
612
+ let currentMaxTurns: number | undefined = agentConfig.maxTurns;
613
+ let currentGraceTurns: number | undefined = __config.agent.graceTurns ?? 6;
614
+ let currentBackground: boolean = __config.agent.forceBackground ?? false;
615
+
616
+ while (true) {
617
+ const displayModel = currentModelStr || "(inherits parent)";
618
+ const displayThinking = currentThinking ?? "inherit";
619
+ const displayMaxTurns = currentMaxTurns != null ? String(currentMaxTurns) : "unlimited";
620
+ const displayGraceTurns = String(currentGraceTurns ?? 6);
621
+ const displayBackground = currentBackground ? "ON" : "OFF";
622
+
623
+ const items = [
624
+ "Spawn",
625
+ "",
626
+ `Model · ${displayModel}`,
627
+ `Background · ${displayBackground}`,
628
+ `Thinking · ${displayThinking}`,
629
+ `Max turns · ${displayMaxTurns}`,
630
+ `Grace turns · ${displayGraceTurns}`,
631
+ `Description · ${description}`,
632
+ ];
633
+
634
+ if (inGitRepo) {
635
+ items.push(`Worktree · ${currentWorktreeLabel}`);
636
+ };
637
+
638
+ const choice = await ctx.ui.select("Spawn Options", items);
639
+ if (choice === undefined) return; // Escape → main menu
640
+
641
+ if (choice === "Spawn") {
642
+ // Resolve model string to Model object
643
+ let model: ReturnType<typeof findModelInRegistry> = undefined;
644
+ let modelKey: string | undefined;
645
+
646
+ if (currentModelStr) {
647
+ const registry = sessionCtx?.modelRegistry ?? ctx.modelRegistry;
648
+ model = findModelInRegistry(currentModelStr, registry, undefined);
649
+ if (!model) {
650
+ ctx.ui.notify(`Model not found: ${currentModelStr}`, "error");
651
+ continue; // Return to options sub-menu
652
+ }
653
+ modelKey = `${model.provider}/${model.id}`;
654
+ }
655
+
656
+ // Discover worktree-local agent types before spawn
657
+ if (currentWorktreePath) {
658
+ await discoverNewAgents(`${currentWorktreePath}/.pi/agents`);
659
+ }
660
+ // Resolve type (may have been discovered from worktree)
661
+ const resolvedType = resolveType(selectedType) ?? selectedType;
662
+
663
+ const spawnOptions: SpawnOptions = {
664
+ description,
665
+ model,
666
+ maxTurns: currentMaxTurns,
667
+ thinkingLevel: currentThinking,
668
+ isBackground: currentBackground,
669
+ modelKey,
670
+ invocation: {
671
+ modelName: model?.id,
672
+ thinking: currentThinking,
673
+ maxTurns: currentMaxTurns,
674
+ runInBackground: currentBackground,
675
+ },
676
+ graceTurns: currentGraceTurns,
677
+ worktreePath: currentWorktreePath,
678
+ worktreeLabel: currentWorktreePath ? currentWorktreeLabel : undefined,
679
+ };
680
+
681
+ const { state: activityState, callbacks } = createActivityTracker(currentMaxTurns);
682
+
683
+ let agentId: string;
684
+ try {
685
+ agentId = getManager().spawn(piInstance, sessionCtx, resolvedType, prompt, {
686
+ ...spawnOptions,
687
+ ...callbacks,
688
+ });
689
+ } catch (err) {
690
+ ctx.ui.notify(
691
+ `Spawn failed: ${err instanceof Error ? err.message : String(err)}`,
692
+ "error",
693
+ );
694
+ return; // Return to main menu
695
+ }
696
+
697
+ // Wire activity tracking for widget
698
+ agentActivity.set(agentId, activityState);
699
+ // Set UI context so widget can render (same as tool_execution_start handler)
700
+ const widget = getWidget();
701
+ if (widget) {
702
+ widget.setUICtx(ctx.ui as unknown as import("./ui/agent-widget.js").UICtx);
703
+ widget.ensureTimer();
704
+ widget.update();
705
+ }
706
+
707
+ if (currentBackground) {
708
+ backgroundAgentIds.add(agentId);
709
+ return; // Background: return to main menu immediately
710
+ }
711
+
712
+ // Foreground: block until completion
713
+ const fgRecord = getManager().getRecord(agentId);
714
+ if (fgRecord?.execution?.promise) {
715
+ await fgRecord.execution.promise;
716
+ }
717
+
718
+ agentActivity.delete(agentId);
719
+ getWidget()?.markFinished(agentId);
720
+ getWidget()?.update();
721
+
722
+ return; // Return to main menu
723
+ }
724
+
725
+ // Handle option changes
726
+ if (choice.startsWith("Description")) {
727
+ const input = await ctx.ui.input("Description", description);
728
+ if (input !== undefined && input.trim()) {
729
+ description = input.trim();
730
+ }
731
+ } else if (choice.startsWith("Model")) {
732
+ const chosen = await promptModelSelection(
733
+ ctx, modelOptions, currentModelStr || "(inherits parent)",
734
+ );
735
+ if (chosen !== null) {
736
+ currentModelStr = chosen === "(inherits parent)" ? "" : chosen;
737
+ }
738
+ } else if (choice.startsWith("Thinking")) {
739
+ const allLevels = [...THINKING_LEVELS, "inherit"];
740
+ const chosen = await ctx.ui.select("Thinking level", allLevels);
741
+ if (chosen !== undefined) {
742
+ currentThinking = chosen === "inherit" ? undefined : (chosen as ThinkingLevel);
743
+ }
744
+ } else if (choice.startsWith("Max turns")) {
745
+ const initial = currentMaxTurns != null ? String(currentMaxTurns) : "unlimited";
746
+ const input = await ctx.ui.input("Max turns (number or 'unlimited')", initial);
747
+ if (input !== undefined) {
748
+ const trimmed = input.trim().toLowerCase();
749
+ if (trimmed === "unlimited" || trimmed === "") {
750
+ currentMaxTurns = undefined;
751
+ } else {
752
+ const parsed = parseInt(trimmed, 10);
753
+ if (isNaN(parsed) || parsed < 1) {
754
+ ctx.ui.notify("Invalid value — must be a number ≥ 1 or 'unlimited'", "error");
755
+ } else {
756
+ currentMaxTurns = parsed;
757
+ }
758
+ }
759
+ }
760
+ } else if (choice.startsWith("Grace turns")) {
761
+ const parsed = await parseNumericInput(ctx, "Grace turns (≥ 0)", String(currentGraceTurns ?? 6), 0, "≥ 0");
762
+ if (parsed !== undefined) currentGraceTurns = parsed;
763
+ } else if (choice.startsWith("Background")) {
764
+ currentBackground = !currentBackground;
765
+ } else if (choice.startsWith("Worktree") && inGitRepo) {
766
+ // Open worktree picker
767
+ const worktrees = await listWorktrees(parentCwd);
768
+ if (!worktrees || worktrees.length === 0) {
769
+ ctx.ui.notify(
770
+ "No worktrees found or git worktree list unavailable",
771
+ "error",
772
+ );
773
+ continue; // Return to options sub-menu
774
+ }
775
+
776
+ const pickerItems = [
777
+ "Inherits parent cwd",
778
+ ...worktrees.map(wt => {
779
+ const branchLabel = wt.isDetached ? "detached" : (wt.branch ?? "detached");
780
+ const truncPath = truncatePath(wt.path);
781
+ return `${branchLabel} · ${truncPath}`;
782
+ }),
783
+ ];
784
+
785
+ const picked = await ctx.ui.select("Select worktree", pickerItems);
786
+ if (picked === undefined) continue; // Escape → return to options sub-menu
787
+
788
+ if (picked === "Inherits parent cwd") {
789
+ currentWorktreePath = undefined;
790
+ currentWorktreeLabel = "Inherits parent cwd";
791
+ } else {
792
+ // Find the matching worktree by index (offset by "Inherits parent cwd")
793
+ const idx = pickerItems.indexOf(picked) - 1;
794
+ if (idx >= 0 && idx < worktrees.length) {
795
+ const wt = worktrees[idx];
796
+ currentWorktreePath = wt.path;
797
+ currentWorktreeLabel = wt.branch ?? "detached";
798
+ }
799
+ }
800
+ }
801
+ }
802
+ }
803
+
804
+ export async function showSettingsMenu(
805
+ ctx: ExtensionCommandContext,
806
+ modelOptions: string[],
807
+ ): Promise<void> {
808
+ const menuItems = [
809
+ "1. Model settings — Set global default and per-type model overrides",
810
+ "2. Concurrency settings — Set per-model slot limits",
811
+ "3. Widget settings — Configure widget display options",
812
+ "",
813
+ "Back",
814
+ ];
815
+
816
+ const handlers: Record<string, () => Promise<void>> = {
817
+ "1": () => showModelSettingsMenu(ctx, modelOptions),
818
+ "2": () => showConcurrencySettingsMenu(ctx, modelOptions),
819
+ "3": () => showWidgetSettingsMenu(ctx),
820
+ };
821
+
822
+ while (true) {
823
+ const choice = await ctx.ui.select("Settings", menuItems);
824
+ if (choice === undefined || choice === "Back") return;
825
+
826
+ const action = matchMenuChoice(choice, handlers);
827
+ if (action) await action();
828
+ }
829
+ }
830
+
442
831
  export async function showAgentsMainMenu(
443
832
  ctx: ExtensionCommandContext,
444
833
  modelOptions: string[],
445
834
  ): Promise<void> {
446
835
  const menuItems = [
447
836
  "1. Running agents — List running/queued agents",
448
- "2. Model settingsSet global default and per-type model overrides",
449
- "3. Concurrency settings Set per-model slot limits",
450
- "4. Widget settings Configure widget display options",
451
- "5. Debug — Agent types, briefing, diagnostics",
837
+ "2. Spawn agentManually spawn a new agent",
838
+ "3. SettingsModel, concurrency, and widget settings",
839
+ "4. DebugAgent types, briefing, diagnostics",
452
840
  "",
453
841
  "Press Escape to close",
454
842
  ];
455
843
 
456
844
  const handlers: Record<string, () => Promise<void>> = {
457
845
  "1": () => showRunningAgentsMenu(ctx),
458
- "2": () => showModelSettingsMenu(ctx, modelOptions),
459
- "3": () => showConcurrencySettingsMenu(ctx, modelOptions),
460
- "4": () => showWidgetSettingsMenu(ctx),
461
- "5": () => showDebugMenu(ctx),
846
+ "2": () => showSpawnAgentMenu(ctx, modelOptions),
847
+ "3": () => showSettingsMenu(ctx, modelOptions),
848
+ "4": () => showDebugMenu(ctx),
462
849
  };
463
850
 
464
851
  // Loop so sub-menus navigate back to root; only Escape at root closes
@@ -572,6 +959,7 @@ async function handleAgentBriefing(ctx: ExtensionCommandContext): Promise<void>
572
959
  lines.push("| `agent` | Which agent type to use (default: general-purpose) |");
573
960
  lines.push("| `thinking` | Optional thinking mode override (e.g., `off`, `minimal`, `low`, `medium`, `high`, `xhigh`) |");
574
961
  lines.push("| `run_in_background` | When `true`, result is auto-delivered — do NOT poll. Continue working while waiting. |");
962
+ lines.push("| `worktree_path` | Optional path to a git worktree of the parent's repo. See below for details. |");
575
963
  lines.push("");
576
964
 
577
965
  // Usage guidelines
@@ -579,6 +967,15 @@ async function handleAgentBriefing(ctx: ExtensionCommandContext): Promise<void>
579
967
  lines.push("- Agents start fresh with their config — they do NOT inherit the parent conversation");
580
968
  lines.push("- For parallel tasks, spawn multiple `run_in_background: true` agents in one turn");
581
969
  lines.push(" → Results are auto-delivered — do NOT poll, the result will arrive when ready");
970
+ lines.push("");
971
+ lines.push("## `worktree_path` Parameter\n");
972
+ lines.push("Use `worktree_path` to run a subagent in a different git worktree of the parent's repository.");
973
+ lines.push("");
974
+ lines.push("- **Optional.** Omit to run the subagent in the parent's working directory (default behavior).");
975
+ lines.push("- **Must be a path** inside a git worktree of the parent's repo, including the main checkout. Not a different repo, not a non-git directory.");
976
+ lines.push("- **Relative paths** are resolved against the parent's working directory.");
977
+ lines.push("- **On failure** the validator returns a specific reason (e.g., 'not a worktree of the parent's repository', 'path does not exist') — use this to self-correct.");
978
+ lines.push("- **Agent type discovery:** The worktree's `.pi/agents/` directory is scanned for agent types when this param is set, so worktree-local types become available to that spawn.");
582
979
  piInstance.sendUserMessage(lines.join("\n"));
583
980
  ctx.ui.notify("Agent briefing sent to LLM", "info");
584
981
  }
package/src/renderer.ts CHANGED
@@ -117,6 +117,9 @@ export function renderSubagentResult(
117
117
  if (d.outputFile as string) {
118
118
  headerLine += `\n ${theme.fg("dim", `tail -f ${d.outputFile}`)}`;
119
119
  }
120
+ if (d.worktreePath as string) {
121
+ headerLine += `\n ${theme.fg("dim", `worktree: ${d.worktreePath}`)}`;
122
+ }
120
123
  inner.addChild(new Text(headerLine, 0, 0));
121
124
 
122
125
  if (expanded && text) {
@@ -153,5 +156,8 @@ function buildFallbackResultLine(
153
156
  if (d?.outputFile) {
154
157
  line += `\n ${theme.fg("dim", `tail -f ${d.outputFile}`)}`;
155
158
  }
159
+ if (d?.worktreePath) {
160
+ line += `\n ${theme.fg("dim", `worktree: ${d.worktreePath}`)}`;
161
+ }
156
162
  return line;
157
163
  }
package/src/state.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * PI runtime doesn't propagate ESM live binding reassignments.
6
6
  */
7
7
 
8
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import type { ExtensionContext, ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
9
  import type { SessionModelOverrides, SubagentsConfig } from "./model-precedence.js";
10
10
  import { DEFAULT_CONFIG } from "./config-io.js";
11
11
  import { AgentManager } from "./agent-manager.js";
@@ -15,6 +15,8 @@ export let sessionOverrides: SessionModelOverrides = { default: null };
15
15
  export let __config: SubagentsConfig = { ...DEFAULT_CONFIG, agent: { ...DEFAULT_CONFIG.agent }, concurrency: { ...DEFAULT_CONFIG.concurrency } };
16
16
  export const agentActivity = new Map<string, AgentActivity>();
17
17
  export let piInstance: ExtensionAPI;
18
+ /** Stored ExtensionContext from session_start — used by menu spawn flow. */
19
+ export let sessionCtx: ExtensionContext;
18
20
 
19
21
  // Holder objects — PI runtime doesn't propagate ESM live binding reassignments
20
22
  const managerHolder: { current?: AgentManager } = {};
@@ -26,6 +28,7 @@ export function setManager(m: AgentManager): void { managerHolder.current = m; }
26
28
  export function clearManager(): void { managerHolder.current = undefined; }
27
29
  export function setWidget(w: AgentWidget | undefined): void { widgetHolder.current = w; }
28
30
  export function setPiInstance(pi: ExtensionAPI): void { piInstance = pi; }
31
+ export function setSessionCtx(ctx: ExtensionContext): void { sessionCtx = ctx; }
29
32
  export function getManager(): AgentManager { return managerHolder.current!; }
30
33
  export function getWidget(): AgentWidget | undefined { return widgetHolder.current; }
31
34
 
@@ -14,6 +14,7 @@ import type { AgentActivity } from "./ui/agent-widget.js";
14
14
  import { resolveType, getAgentConfig, discoverNewAgents } from "./agent-types.js";
15
15
  import { resolveModel } from "./model-precedence.js";
16
16
  import { addUsage, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage } from "./usage.js";
17
+ import { validateWorktreePath } from "./worktree-validator.js";
17
18
 
18
19
  // Shared state imported from state.ts
19
20
  import { parseModelKey, findModelInRegistry, parseThinkingLevel } from "./utils.js";
@@ -24,6 +25,7 @@ import {
24
25
  agentActivity,
25
26
  getManager,
26
27
  getWidget,
28
+ sessionCtx,
27
29
  } from "./state.js";
28
30
 
29
31
  // ============================================================================
@@ -60,8 +62,9 @@ export function errorResult(text: string, details?: Record<string, unknown>) {
60
62
  /**
61
63
  * Create an AgentActivity state and spawn callbacks for tracking tool usage.
62
64
  * Used by both foreground and background paths to avoid duplication.
65
+ * Exported for use by the menu spawn flow.
63
66
  */
64
- function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
67
+ export function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
65
68
  const state: AgentActivity = {
66
69
  activeTools: new Map(),
67
70
  toolUses: 0,
@@ -136,6 +139,10 @@ export function buildAgentDetails(
136
139
  description: record.display.description,
137
140
  };
138
141
 
142
+ if (record.display.worktreePath) {
143
+ details.worktreePath = record.display.worktreePath;
144
+ }
145
+
139
146
  if (options?.includeStatus) {
140
147
  details.status = record.lifecycle.status;
141
148
  details.outputFile = record.display.outputFile;
@@ -191,7 +198,7 @@ function emitIndividualNudge(agentId: string, record?: AgentRecord): void {
191
198
  piInstance.sendMessage(
192
199
  {
193
200
  customType: "subagent-result",
194
- content: `[Subagent "${record.display.type}" completed]\n\n${record.result ?? ""}`,
201
+ content: `[Subagent "${record.display.type}" ${record.lifecycle.status}]\n\n${record.result ?? ""}`,
195
202
  details,
196
203
  display: true,
197
204
  },
@@ -213,11 +220,32 @@ export async function executeAgentTool(
213
220
  _onUpdate: ((update: any) => void) | undefined,
214
221
  ctx: ExtensionContext,
215
222
  ): Promise<any> {
223
+ // Validate worktree_path early — needed for on-demand agent discovery
224
+ const rawWorktreePath = params.worktree_path as string | undefined;
225
+ let validatedWorktreePath: string | undefined;
226
+ let worktreeLabel: string | undefined;
227
+ if (rawWorktreePath && rawWorktreePath.trim() !== "") {
228
+ try {
229
+ const parentCwd = sessionCtx?.cwd ?? ctx.cwd;
230
+ const validation = await validateWorktreePath(piInstance, rawWorktreePath, parentCwd);
231
+ if (!validation.ok) {
232
+ return errorResult(validation.error);
233
+ }
234
+ validatedWorktreePath = validation.resolvedPath;
235
+ worktreeLabel = validation.label;
236
+ } catch (err: unknown) {
237
+ const msg = err instanceof Error ? err.message : String(err);
238
+ return errorResult(`worktree_path validation failed: ${msg}`);
239
+ }
240
+ }
241
+
216
242
  const type = (params.agent as string) || "general-purpose";
217
243
  let resolvedType = resolveType(type);
218
244
  if (!resolvedType) {
219
- // Not found in registry — try scanning filesystem for agents added during the session
220
- await discoverNewAgents();
245
+ // Not found in registry — try scanning filesystem for agents added during the session.
246
+ // When worktree_path is set, also scan the worktree's .pi/agents/ directory.
247
+ const worktreeDir = validatedWorktreePath ? `${validatedWorktreePath}/.pi/agents` : undefined;
248
+ await discoverNewAgents(worktreeDir);
221
249
  resolvedType = resolveType(type);
222
250
  }
223
251
  if (!resolvedType) {
@@ -228,6 +256,7 @@ export async function executeAgentTool(
228
256
  const description = params.description as string;
229
257
  const runInBackground = params.run_in_background as boolean | undefined;
230
258
  const maxTurns = params.max_turns as number | undefined ?? getAgentConfig(resolvedType)?.maxTurns;
259
+
231
260
  const modelStr = params.model as string | undefined;
232
261
  const model = findModelInRegistry(modelStr, ctx.modelRegistry, ctx.model);
233
262
  const modelKey = model ? `${model.provider}/${model.id}` : undefined;
@@ -247,6 +276,8 @@ export async function executeAgentTool(
247
276
  modelKey,
248
277
  invocation: { modelName },
249
278
  graceTurns: __config.agent.graceTurns,
279
+ worktreePath: validatedWorktreePath,
280
+ worktreeLabel,
250
281
  };
251
282
 
252
283
  if (runInBackground || __config.agent.forceBackground) {
package/src/types.ts CHANGED
@@ -142,6 +142,10 @@ export interface AgentDisplayInfo {
142
142
  invocation?: AgentInvocation;
143
143
  /** The tool_use_id from the original Agent tool call. */
144
144
  toolCallId?: string;
145
+ /** Resolved absolute path of the worktree this agent is running in. */
146
+ worktreePath?: string;
147
+ /** Short display label for the worktree (e.g., "feature" or "feature/packages/web"). */
148
+ worktreeLabel?: string;
145
149
  }
146
150
 
147
151
  /**
@@ -362,11 +362,18 @@ export class AgentWidget {
362
362
  const truncate = (line: string) => truncateToWidth(line, w);
363
363
  const blocks: RenderBlock[] = [];
364
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
+ }
365
374
  blocks.push({
366
375
  header: truncate(`${theme.fg("dim", BRANCH)} ${this.renderFinishedLine(a, theme)}`),
367
- continuations: a.display.outputFile
368
- ? [truncate(theme.fg("dim", `${VLINE} tail -f ${a.display.outputFile}`))]
369
- : [],
376
+ continuations,
370
377
  });
371
378
  }
372
379
  return blocks;
@@ -398,14 +405,17 @@ export class AgentWidget {
398
405
  } else {
399
406
  // Full: header + continuation lines
400
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}`)));
401
416
  blocks.push({
402
417
  header: truncate(headerLine),
403
- continuations: [
404
- ...(a.display.outputFile
405
- ? [truncate(`${VLINE} ` + theme.fg("dim", `${VLINE} tail -f ${a.display.outputFile}`))]
406
- : []),
407
- truncate(`${VLINE} ` + theme.fg("dim", `└ ${activity}`)),
408
- ],
418
+ continuations,
409
419
  });
410
420
  }
411
421
  }
@@ -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
+ }