@pify/worktree 0.2.0 → 0.3.1

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
@@ -1,25 +1,84 @@
1
1
  # @pify/worktree
2
2
 
3
- Safe git-worktree management for [pi](https://github.com/earendil-works/pi) — isolated workspaces for parallel or risky changes, with safety rails everywhere and a clean merge-back flow. Windows-first: no tmux, no daemons, no shell interpolation.
3
+ Safe git-worktree management for [pi](https://github.com/earendil-works/pi) — isolated workspaces for parallel or risky changes, with safety rails on every destructive path and a clean merge-back flow. Windows-first: no tmux, no daemons, no shell interpolation.
4
4
 
5
5
  Part of the [Pify suite](https://github.com/pifydev). Install with [`pify install worktree`](https://github.com/pifydev/cli) or `pi install npm:@pify/worktree`.
6
6
 
7
- ## What it does
7
+ ## Why
8
8
 
9
- - **`worktree_create`**new branch (or existing unoccupied one) checked out under `~/.worktrees/<repo>/<branch>`; reports the base commit and how to open pi there. The main checkout stays untouched.
10
- - **`worktree_list`** — every worktree with branch, `primary`/`dirty`/`locked`/`prunable` flags.
11
- - **`worktree_merge`** — with your confirmation: merges the worktree's branch into the primary branch, then removes the worktree. **Conflicting merges abort cleanly** — the primary is restored, nothing half-merged.
12
- - **`worktree_remove`** — refuses the primary worktree, the one the session runs in, and locked ones outright; uncommitted changes need your explicit confirmation (fail-closed without a UI). The branch is always kept.
13
- - **`/worktree`** — `list` / `create <branch> [base]` / `remove <target>` / `merge <branch>` / `prune` for humans. Everything after the route is taken whole, so paths with spaces work (v0.2).
14
- - **Targets resolve the way you'd name them** (v0.2): a branch, a path, a directory name, or — for worktrees created by `isolation: "worktree"` in `@pify/subagent`/`swarm`/`workflow` — the agent slug alone (`worker-1` finds branch `agent/worker-1`).
9
+ Two changes that touch the same files cannot share one checkout. The usual workarounds stashing, branching back and forth, or just being careful all fail the same way: work gets lost, or the main checkout ends up in a state nobody can build. A worktree gives each line of work its own directory and its own branch, and git already knows how to do it. What was missing was a way to drive it that refuses to destroy anything by accident, and a way to actually *go there* without abandoning the conversation.
10
+
11
+ ## Tools
12
+
13
+ ### `worktree_create`
14
+
15
+ | Parameter | Type | Notes |
16
+ |---|---|---|
17
+ | `branch` | string | A new branch, or an existing one not checked out anywhere |
18
+ | `base` | string, optional | Base ref for a new branch; defaults to HEAD |
19
+
20
+ Creates the worktree under `~/.worktrees/<repo>/<branch>` and reports the path and base commit. The main checkout is untouched. The result also says plainly that the agent's own tools still point at the main checkout — only you can move the session (see below).
21
+
22
+ ### `worktree_list`
23
+
24
+ No parameters. Every worktree with its branch and `primary` / `dirty` / `locked` / `prunable` flags.
25
+
26
+ ### `worktree_merge`
27
+
28
+ | Parameter | Type | Notes |
29
+ |---|---|---|
30
+ | `branch` | string | Branch of the worktree to merge back |
31
+
32
+ With your confirmation: merges into the primary worktree's branch, then removes the worktree (keeping the branch). Both sides must be clean first. **A conflicting merge aborts cleanly** — the primary is restored and nothing is left half-merged.
33
+
34
+ ### `worktree_remove`
35
+
36
+ | Parameter | Type | Notes |
37
+ |---|---|---|
38
+ | `target` | string | Branch name or worktree path |
39
+
40
+ Refuses the primary worktree, the one this session runs in, and locked ones outright. Uncommitted changes require your explicit confirmation, and are refused outright when there is no UI to ask through. The branch itself is always kept.
41
+
42
+ ## Entering a worktree
43
+
44
+ Creating a worktree used to be half the job. pi binds `read`, `edit`, `bash` and `@` completion to the session's working directory, and a session cannot change its own — so the worktree existed, and everything you had just discussed stayed in the terminal you were in.
45
+
46
+ `/worktree enter <branch|path>` forks the current session into the worktree and switches to it. The conversation comes along, the tools rebind, and the branch you were reading about is the branch you are now in. `/worktree exit` returns to the session you came from. `/worktree create <branch> --enter` does both in one step.
47
+
48
+ Two refusals, each with a different fix:
49
+
50
+ - **`--no-session`** — entering forks a session file, so there has to be one.
51
+ - **A session pi has not written yet** — pi keeps a session in memory until the agent has replied, so a brand-new session has nothing on disk to fork. Ask something first, or open the worktree in its own pi.
52
+
53
+ Session switching is a user command, so **the agent cannot move itself**. That is why `worktree_create` says so in its result instead of implying the tools followed it there.
54
+
55
+ ## Command
56
+
57
+ `/worktree` — the same list as `worktree_list`.
58
+ `/worktree create <branch> [base] [--enter]` — create, optionally entering it.
59
+ `/worktree enter <branch|path>` — take the conversation into a worktree.
60
+ `/worktree exit` — return to the session you came from.
61
+ `/worktree remove <branch|path>` — remove, with the same rails as the tool.
62
+ `/worktree merge <branch>` — merge back and clean up.
63
+ `/worktree prune` — drop administrative records for worktrees whose directories are gone.
64
+
65
+ Everything after the route is taken whole, so paths with spaces work.
66
+
67
+ ## Targets resolve the way you would name them
68
+
69
+ A branch, a full path, a directory name — or, for worktrees created by `isolation: "worktree"` in [`@pify/subagent`](https://github.com/pifydev/subagent), [`@pify/swarm`](https://github.com/pifydev/swarm) or [`@pify/workflow`](https://github.com/pifydev/workflow), the agent slug alone: `worker-1` finds branch `agent/worker-1`.
15
70
 
16
71
  ## Safety model
17
72
 
18
- Every git call is an `execFile` argv — no shell, no string interpolation, ever. Branch names are validated against a restricted grammar (no leading `-`, no `..`, no ref tricks) before reaching git. Removal risk is assessed (primary / current-session / locked / dirty) before anything happens — and containment is checked on directory boundaries (v0.2), so sitting in `feature-2` no longer blocks removing `feature`, and integration tests run the whole create→merge→remove and conflict-abort flows against real repositories.
73
+ Every git call is an `execFile` argv — no shell, no string interpolation, ever. Branch names are validated against a restricted grammar (no leading `-`, no `..`, no ref tricks) before they reach git.
74
+
75
+ Removal risk is assessed before anything happens: primary, current-session, locked, and dirty are four distinct verdicts, and only dirty is confirmable. Containment is checked on directory boundaries, so sitting in `feature-2` does not block removing `feature`.
76
+
77
+ Integration tests run the whole create → merge → remove flow, and the conflict-abort path, against real repositories rather than mocks.
19
78
 
20
79
  ## Where this sits in the suite
21
80
 
22
- `@pify/subagent` and `@pify/swarm` run agents in-place (shared files fine for read-mostly work). Worktrees are the isolation layer for parallel **mutating** work; spawning agents directly into worktrees is planned v0.2 integration across the three packages.
81
+ `@pify/subagent` and `@pify/swarm` run agents in place shared files, which is fine for read-mostly work. Worktrees are the isolation layer for parallel **mutating** work, and all three of the agent packages can create one per child with `isolation: "worktree"`. A worktree whose child changed nothing is removed automatically; anything uncommitted is kept and reported.
23
82
 
24
83
  ## License
25
84
 
@@ -16,7 +16,14 @@
16
16
  * (@narumitw/pi-worktree), merge-back cleanup flow (rielj/pi-git-worktrees,
17
17
  * minus the tmux), worktree-as-concurrency-safety framing (pi-napkin).
18
18
  */
19
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
19
+ import {
20
+ SessionManager,
21
+ type ExtensionAPI,
22
+ type ExtensionCommandContext,
23
+ type ExtensionContext,
24
+ } from "@earendil-works/pi-coding-agent";
25
+ import { existsSync, statSync } from "node:fs";
26
+
20
27
  import { Type } from "typebox";
21
28
 
22
29
  import {
@@ -29,8 +36,17 @@ import {
29
36
  repoToplevel,
30
37
  } from "../src/git.ts";
31
38
  import { assessRemoval, formatWorktrees, resolveWorktree, validBranchName } from "../src/parse.ts";
39
+ import {
40
+ WORKTREE_SESSION_ENTRY,
41
+ enteredNote,
42
+ exitNote,
43
+ planEnter,
44
+ readWorktreeSession,
45
+ type WorktreeSession,
46
+ } from "../src/enter.ts";
32
47
 
33
48
  type UiContext = ExtensionContext;
49
+ type CommandContext = ExtensionCommandContext;
34
50
 
35
51
  export default function worktree(pi: ExtensionAPI) {
36
52
  function requireRepo(ctx: UiContext): string {
@@ -102,6 +118,81 @@ export default function worktree(pi: ExtensionAPI) {
102
118
  };
103
119
  }
104
120
 
121
+ /**
122
+ * Enter a worktree by forking this session into it. pi binds its built-in
123
+ * tools to the session cwd and a session cannot change its own, so the only
124
+ * way to take the conversation along is a replacement session.
125
+ */
126
+ async function enterWorktree(ctx: CommandContext, wanted: string, created = false): Promise<void> {
127
+ requireRepo(ctx);
128
+ if (typeof ctx.switchSession !== "function") {
129
+ ctx.ui.notify("This pi build cannot switch sessions, so /worktree enter is unavailable.", "error");
130
+ return;
131
+ }
132
+
133
+ const match = resolveWorktree(listWorktrees(ctx.cwd), wanted);
134
+ const file = ctx.sessionManager.getSessionFile() ?? null;
135
+ const plan = planEnter(
136
+ ctx.cwd,
137
+ { file, onDisk: Boolean(file && existsSync(file) && statSync(file).size > 0) },
138
+ match ? { path: match.path, branch: match.branch } : null,
139
+ );
140
+ if ("kind" in plan) {
141
+ ctx.ui.notify(plan.message, plan.kind === "already-here" ? "info" : "warning");
142
+ return;
143
+ }
144
+
145
+ const state: WorktreeSession = {
146
+ path: plan.target.path,
147
+ branch: plan.target.branch,
148
+ parentSession: plan.parentSession,
149
+ created,
150
+ enteredAt: Date.now(),
151
+ };
152
+
153
+ try {
154
+ // forkFrom copies the conversation into a session file rooted at the
155
+ // worktree; switching to it is what rebinds read/edit/bash and @.
156
+ const replacement = SessionManager.forkFrom(plan.parentSession, state.path);
157
+ const replacementFile = replacement.getSessionFile();
158
+ if (!replacementFile) throw new Error("the forked session has no file");
159
+ replacement.appendCustomEntry(WORKTREE_SESSION_ENTRY, state);
160
+ const { cancelled } = await ctx.switchSession(replacementFile, {
161
+ withSession: async (next) => {
162
+ next.ui.notify(enteredNote(state), "info");
163
+ },
164
+ });
165
+ if (cancelled) ctx.ui.notify("Staying put — the session switch was cancelled.", "info");
166
+ } catch (err) {
167
+ ctx.ui.notify(`Could not enter: ${err instanceof Error ? err.message : String(err)}`, "error");
168
+ }
169
+ }
170
+
171
+ /** Return to the session this one was forked from. */
172
+ async function exitWorktree(ctx: CommandContext): Promise<void> {
173
+ const state = readWorktreeSession(ctx.sessionManager.getBranch() as never);
174
+ if (!state) {
175
+ ctx.ui.notify("This session was not entered with /worktree enter.", "warning");
176
+ return;
177
+ }
178
+ if (!state.parentSession || !existsSync(state.parentSession)) {
179
+ ctx.ui.notify(
180
+ "The session this was forked from is gone, so there is nowhere to go back to. This session stays in the worktree.",
181
+ "warning",
182
+ );
183
+ return;
184
+ }
185
+ try {
186
+ await ctx.switchSession(state.parentSession, {
187
+ withSession: async (next) => {
188
+ next.ui.notify(exitNote(state), "info");
189
+ },
190
+ });
191
+ } catch (err) {
192
+ ctx.ui.notify(`Could not exit: ${err instanceof Error ? err.message : String(err)}`, "error");
193
+ }
194
+ }
195
+
105
196
  // ── Tools ────────────────────────────────────────────────────────────
106
197
 
107
198
  pi.registerTool({
@@ -146,7 +237,10 @@ export default function worktree(pi: ExtensionAPI) {
146
237
  type: "text",
147
238
  text: [
148
239
  `Worktree ready at ${result.path} (${result.message} base: ${result.base}).`,
149
- `Open a pi session there with: cd "${result.path}" && pi`,
240
+ // Only the user can switch sessions, so tell them how rather than
241
+ // implying this session moved.
242
+ `Your tools still point at the main checkout. The user can run /worktree enter ${branch} to ` +
243
+ `bring this conversation into the worktree, or open it separately with: cd "${result.path}" && pi`,
150
244
  `When the work is done: worktree_merge branch="${branch}" merges it back and cleans up.`,
151
245
  ].join("\n"),
152
246
  },
@@ -229,7 +323,8 @@ export default function worktree(pi: ExtensionAPI) {
229
323
  // ── Command ──────────────────────────────────────────────────────────
230
324
 
231
325
  pi.registerCommand("worktree", {
232
- description: "Manage git worktrees: /worktree [create <branch> [base] | remove <target> | merge <branch> | prune]",
326
+ description:
327
+ "Manage git worktrees: /worktree [create <branch> [base] [--enter] | enter <target> | exit | remove <target> | merge <branch> | prune]",
233
328
  handler: async (args, ctx) => {
234
329
  if (!ctx.hasUI) return;
235
330
  const text = (args ?? "").trim();
@@ -244,9 +339,11 @@ export default function worktree(pi: ExtensionAPI) {
244
339
  return;
245
340
  }
246
341
  case "create": {
247
- const [arg, base] = rest.split(/\s+/);
342
+ const words = rest.split(/\s+/).filter(Boolean);
343
+ const enterAfter = words.includes("--enter");
344
+ const [arg, base] = words.filter((w) => w !== "--enter");
248
345
  if (!arg) {
249
- ctx.ui.notify("Usage: /worktree create <branch> [base]", "warning");
346
+ ctx.ui.notify("Usage: /worktree create <branch> [base] [--enter]", "warning");
250
347
  return;
251
348
  }
252
349
  if (!validBranchName(arg)) {
@@ -254,9 +351,19 @@ export default function worktree(pi: ExtensionAPI) {
254
351
  return;
255
352
  }
256
353
  const result = createWorktree(ctx.cwd, arg, base);
354
+ if (!result.ok) {
355
+ ctx.ui.notify(result.message, "error");
356
+ return;
357
+ }
358
+ if (enterAfter) {
359
+ ctx.ui.notify(`Worktree ready: ${result.path}`, "info");
360
+ await enterWorktree(ctx, arg, true);
361
+ return;
362
+ }
257
363
  ctx.ui.notify(
258
- result.ok ? `Worktree ready: ${result.path}\nOpen with: cd "${result.path}" && pi` : result.message,
259
- result.ok ? "info" : "error",
364
+ `Worktree ready: ${result.path}\n` +
365
+ `/worktree enter ${arg} takes this conversation there, or open it separately with: cd "${result.path}" && pi`,
366
+ "info",
260
367
  );
261
368
  return;
262
369
  }
@@ -287,6 +394,18 @@ export default function worktree(pi: ExtensionAPI) {
287
394
  ctx.ui.notify(result.ok ? `Removed ${target.path}.` : result.output, result.ok ? "info" : "error");
288
395
  return;
289
396
  }
397
+ case "enter": {
398
+ if (!rest) {
399
+ ctx.ui.notify("Usage: /worktree enter <branch|path>", "warning");
400
+ return;
401
+ }
402
+ await enterWorktree(ctx, rest);
403
+ return;
404
+ }
405
+ case "exit": {
406
+ await exitWorktree(ctx);
407
+ return;
408
+ }
290
409
  case "merge": {
291
410
  if (!rest) {
292
411
  ctx.ui.notify("Usage: /worktree merge <branch>", "warning");
@@ -303,7 +422,7 @@ export default function worktree(pi: ExtensionAPI) {
303
422
  }
304
423
  default:
305
424
  ctx.ui.notify(
306
- `Unknown route "${route}". Usage: /worktree [list | create <branch> [base] | remove <target> | merge <branch> | prune]`,
425
+ `Unknown route "${route}". Usage: /worktree [list | create <branch> [base] [--enter] | enter <target> | exit | remove <target> | merge <branch> | prune]`,
307
426
  "warning",
308
427
  );
309
428
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/worktree",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Safe git-worktree management for pi: create/list/merge/remove with safety rails, no shell interpolation, Windows-first, zero tmux",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -57,7 +57,7 @@
57
57
  }
58
58
  },
59
59
  "devDependencies": {
60
- "@earendil-works/pi-coding-agent": "^0.84.4",
60
+ "@earendil-works/pi-coding-agent": "^0.85.1",
61
61
  "@types/node": "^22.10.2",
62
62
  "typebox": "^1.1.38",
63
63
  "typescript": "^5.7.2"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: worktree
3
- description: Use when work should happen in isolation from the main checkout - risky refactors, parallel efforts, or long-running changes - explains the worktree tools, safety rails, and the merge-back flow
3
+ description: Use when work should happen in isolation from the main checkout - risky refactors, parallel efforts, or long-running changes
4
4
  ---
5
5
 
6
6
  # Git worktrees
@@ -21,13 +21,16 @@ edits.
21
21
 
22
22
  1. `worktree_list` — see what exists (primary, dirty, locked flags).
23
23
  2. `worktree_create branch="feature-x"` — new branch from HEAD (or pass
24
- `base`), checked out under `~/.worktrees/<repo>/`. Tell the user the
25
- path they can run pi there (`cd <path> && pi`).
26
- 3. Work happens in the worktree; commit there.
27
- 4. `worktree_merge branch="feature-x"` asks the user, merges into the
24
+ `base`), checked out under `~/.worktrees/<repo>/`.
25
+ 3. Say where the worktree is and that your tools still point at the main
26
+ checkout. Only the user can move this session: `/worktree enter feature-x`
27
+ brings the conversation into the worktree, `/worktree exit` returns.
28
+ Otherwise they can open it separately with `cd <path> && pi`.
29
+ 4. Work happens in the worktree; commit there.
30
+ 5. `worktree_merge branch="feature-x"` — asks the user, merges into the
28
31
  primary branch, removes the worktree. Conflicts abort cleanly; nothing
29
32
  is left half-merged.
30
- 5. `worktree_remove target="feature-x"` — abandon instead; dirty worktrees
33
+ 6. `worktree_remove target="feature-x"` — abandon instead; dirty worktrees
31
34
  need the user's confirmation, the branch is always kept.
32
35
 
33
36
  ## Rules
@@ -35,3 +38,5 @@ edits.
35
38
  - Never try to bypass a refusal (primary/current/locked worktrees).
36
39
  - Commit inside the worktree before merging — both sides must be clean.
37
40
  - One branch per worktree; a branch already checked out elsewhere refuses.
41
+ - Do not claim you are "now working in" a worktree you created. Until the user
42
+ enters it, every path you read and edit is still the main checkout.
package/src/enter.ts ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Entering a worktree without losing the conversation.
3
+ *
4
+ * Creating a worktree was only ever half the job: pi binds `read`, `edit`,
5
+ * `bash` and `@` completion to the session's cwd, and a session cannot change
6
+ * its own cwd. So until now this package could hand you a path and a
7
+ * suggestion to open a second terminal — and everything you had discussed
8
+ * stayed in the terminal you left.
9
+ *
10
+ * The way through is a replacement session: fork the current session file
11
+ * into the worktree and switch to it. The conversation comes along, the tools
12
+ * rebind, and the branch you were reading about is the branch you are now in.
13
+ * (Mechanism from FradSer/pi-packages' utils, which found it first.)
14
+ *
15
+ * Pure helpers only — the switch itself needs the host and lives in the
16
+ * extension.
17
+ */
18
+
19
+ export const WORKTREE_SESSION_ENTRY = "pify-worktree-session";
20
+
21
+ export interface WorktreeSession {
22
+ /** Absolute path of the worktree this session is rooted in. */
23
+ path: string;
24
+ branch: string | null;
25
+ /** Session file we forked from, so ExitWorktree knows where to go back. */
26
+ parentSession: string;
27
+ /** True when entering created the worktree, so leaving may offer to remove it. */
28
+ created: boolean;
29
+ enteredAt: number;
30
+ }
31
+
32
+ export interface BranchEntryLike {
33
+ type?: string;
34
+ customType?: string;
35
+ data?: unknown;
36
+ [key: string]: unknown;
37
+ }
38
+
39
+ /** The worktree state this session was entered with, if any. */
40
+ export function readWorktreeSession(entries: readonly BranchEntryLike[]): WorktreeSession | null {
41
+ let state: WorktreeSession | null = null;
42
+ for (const entry of entries) {
43
+ if (entry.type !== "custom" || entry.customType !== WORKTREE_SESSION_ENTRY) continue;
44
+ const data = entry.data as Partial<WorktreeSession> | null;
45
+ if (!data || typeof data.path !== "string" || !data.path) continue;
46
+ state = {
47
+ path: data.path,
48
+ branch: typeof data.branch === "string" ? data.branch : null,
49
+ parentSession: typeof data.parentSession === "string" ? data.parentSession : "",
50
+ created: data.created === true,
51
+ enteredAt: typeof data.enteredAt === "number" ? data.enteredAt : 0,
52
+ };
53
+ }
54
+ return state;
55
+ }
56
+
57
+ export type EnterProblem =
58
+ | { kind: "no-session"; message: string }
59
+ | { kind: "unwritten"; message: string }
60
+ | { kind: "not-found"; message: string }
61
+ | { kind: "already-here"; message: string };
62
+
63
+ export interface EnterPlan {
64
+ target: { path: string; branch: string | null };
65
+ parentSession: string;
66
+ }
67
+
68
+ /** What we know about the session we would be forking. */
69
+ export interface ParentSession {
70
+ file: string | null;
71
+ /**
72
+ * Whether that file exists with entries in it. pi keeps a session in memory
73
+ * until the first assistant message, so a brand-new session has a name on
74
+ * disk and nothing behind it — and forking from it throws.
75
+ */
76
+ onDisk: boolean;
77
+ }
78
+
79
+ function samePath(a: string, b: string): boolean {
80
+ return a.replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase() ===
81
+ b.replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase();
82
+ }
83
+
84
+ /**
85
+ * Decide whether entering is possible before anything is forked. Each refusal
86
+ * names which of the four things went wrong, because the fixes differ: start a
87
+ * persisted session, say something first, create the worktree, or do nothing
88
+ * at all.
89
+ */
90
+ export function planEnter(
91
+ cwd: string,
92
+ parent: ParentSession,
93
+ target: { path: string; branch: string | null } | null,
94
+ ): EnterPlan | EnterProblem {
95
+ if (!parent.file) {
96
+ return {
97
+ kind: "no-session",
98
+ message:
99
+ "Entering a worktree forks this session, so it needs a persisted one. Start pi without --no-session.",
100
+ };
101
+ }
102
+ if (!parent.onDisk) {
103
+ return {
104
+ kind: "unwritten",
105
+ message:
106
+ "This session has not been written to disk yet — pi saves it once the agent has replied, and there is " +
107
+ "nothing to carry across until then. Ask something first, or open the worktree in its own pi.",
108
+ };
109
+ }
110
+ if (!target) {
111
+ return {
112
+ kind: "not-found",
113
+ message: "No worktree matches that. /worktree list shows them; /worktree create <branch> makes one.",
114
+ };
115
+ }
116
+ if (samePath(cwd, target.path)) {
117
+ return { kind: "already-here", message: "This session is already rooted in that worktree." };
118
+ }
119
+ return { target, parentSession: parent.file };
120
+ }
121
+
122
+ export function enteredNote(session: WorktreeSession): string {
123
+ const branch = session.branch ? ` on ${session.branch}` : "";
124
+ return [
125
+ `Entered worktree ${session.path}${branch}.`,
126
+ "read, edit, bash and @ completion are rooted here now; the conversation came with you.",
127
+ "/worktree exit returns to the session you came from.",
128
+ ].join(" ");
129
+ }
130
+
131
+ export function exitNote(session: WorktreeSession): string {
132
+ return [
133
+ `Left the worktree at ${session.path}.`,
134
+ session.created
135
+ ? "It was created by entering, and is still there — /worktree remove drops it, worktree_merge merges it back."
136
+ : "It is untouched.",
137
+ ].join(" ");
138
+ }