pi-odd-worktree 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
@@ -15,7 +15,7 @@ alone; Herdr is optional.
15
15
  | `worktree_create` | tool | Creates a branch and its checkout from the **local** `main`, registers it, runs the creation hooks, and copies the pending changes you name. |
16
16
  | `worktree_warp` | tool | Queues the session move to another checkout as a `/warp` handoff; the command performs the move when the turn ends. |
17
17
  | `worktree_close` | tool | Removes a registered checkout with `git worktree remove`, keeping the branch, and refuses while a live Pi session is working in it. |
18
- | `/warp` | panel | Moves this session to another checkout: no argument opens the selector (list, create, delete, extended list); `/warp <name>` jumps straight there. |
18
+ | `/warp` | panel | Moves this session to another checkout: no argument opens the selector (list, create, delete, extended list); `/warp <name>` jumps straight there. A session with no conversation yet changes checkout **silently** — no fork, no `Warp · moved` card, nothing to carry — and warping back from it is the same empty case. |
19
19
  | `/odd-list` | panel | Two-pane reader of this checkout's `odd/tasks/*.md`: list on the left, styled markdown on the right. |
20
20
  | `/issues` | panel | Full-screen reader of this checkout's repository issues — My issues / Assigned / Involved / All — with preview, comments, labels, assignees and close, gated on your repository role. |
21
21
  | `/codegraph-hook` | command | **Only when the CodeGraph CLI is installed.** Turns the edit/write sync on or off for the session. |
@@ -34,7 +34,6 @@ interchangeably by `worktree_close`, and by `/warp`.
34
34
  | --- | --- |
35
35
  | Pi (`@earendil-works/pi-coding-agent`, `@earendil-works/pi-tui`) | Peer dependencies at `*`, supplied by the host. The package installs no runtime dependencies. |
36
36
  | Node.js >= 18 and `git` | `engines` declares 18 as the floor, and every repository operation is a git child process. |
37
- | `herdr` — optional | Not required: only `worktree_create` tries a Herdr workspace association, and it falls back to plain `git`. |
38
37
  | `gh`, authenticated (`gh auth login`) | `/issues` only. Without it the command stays registered, says once per session what is missing, and never opens. |
39
38
  | `codegraph` — optional | Keeps each checkout's index fresh: `codegraph init` when a checkout is created, and a background `codegraph sync` after edits and writes. Absent, both stay off for the whole session. |
40
39
  | Gentle Shell — recommended | Supplies the presence and transport records the deletion guard reads to see sessions other than this one. |
@@ -154,6 +153,24 @@ Removal is `git worktree remove <absolute path>`, with `--force` only when the
154
153
  caller asked for it. The branch is kept, and **no Herdr workspace is closed,
155
154
  opened or removed** on any path.
156
155
 
156
+ ### A warp waits for this session's own agent tasks
157
+
158
+ A warp never lands while this session still has running or queued agent tasks.
159
+ The `worktree_warp` tool refuses **before** it queues anything, and `/warp`
160
+ gives the published counters a bounded chance to drain (`ODD_WORKTREE_WARP_BUSY_WAIT_MS`,
161
+ 60 s by default) before it refuses: the session stays where it is and the notice
162
+ says how many tasks are running. The counters are read from Gentle Shell's
163
+ presence header for this session, through the same bounded reader the deletion
164
+ guard uses.
165
+
166
+ That check is **best effort, and it does not see background jobs**:
167
+
168
+ - Agents publish their task counters in the presence header; a heartbeat older
169
+ than 15 s is not evidence, and an absent or unreadable store blocks nothing.
170
+ - `bash_background` and `monitor` jobs live in Gentle Shell's in-memory job
171
+ registry, which publishes nothing durable and is not exposed by the Pi host,
172
+ so no warp can observe them. Confirm a job finished before warping.
173
+
157
174
  ### `/issues` pins the repository
158
175
 
159
176
  The repository identity (`<host>/<owner>/<repo>`) is pinned when the list loads,
@@ -238,7 +255,7 @@ This package is the worktree / ODD / issues surface only: no customize panel, no
238
255
  plugin-tools toggles, no Herdr plugin management, and no worktree↔issue
239
256
  association. There is nothing to configure, and `/warp`, `/odd-list` and
240
257
  `/issues` are always on (the three `/codegraph-*` commands follow the CLI).
241
- Paths, branches and arguments reach `git`, `herdr` and `gh` as fixed argv arrays,
258
+ Paths, branches and arguments reach `git` and `gh` as fixed argv arrays,
242
259
  never as a shell string.
243
260
 
244
261
  ## Tests
@@ -14,8 +14,7 @@
14
14
  // worktree_warp. Create and close prompt for approval when the host offers
15
15
  // dialog UI; list never mutates anything; warp hands the move to /warp.
16
16
  //
17
- // Herdr is optional, never required: every Herdr call is attempted first and
18
- // falls back to plain git, so the package works in Pi alone.
17
+ // Worktree creation and removal use git; no Herdr workspace is opened.
19
18
  //
20
19
  // The file is plain JavaScript with a .ts extension (// @ts-nocheck, no type
21
20
  // annotations), so the test copy-to-.mjs loading pattern keeps working under
@@ -69,54 +68,6 @@ function setWarpTuiLoader(loader) {
69
68
  warpTuiLoader = loader;
70
69
  }
71
70
 
72
- // The herdr binary honors the documented HERDR_BIN_PATH override (tests),
73
- // else `herdr` on PATH.
74
- //
75
- // lib/worktree-git.js `resolveHerdrBinary` is the SINGLE authority and its
76
- // answer is propagated verbatim, including its refusal: under the test harness
77
- // (GENTLE_WORKTREE_LIB_DIR, set only by the test loaders) a value that may be
78
- // the live binary is refused unless the CURRENT test opted in with
79
- // GENTLE_WORKTREE_ALLOW_LIVE_HERDR=1. A `catch {}` around that call used to
80
- // swallow the refusal and then re-apply an inherited HERDR_BIN_PATH here, which
81
- // handed a test process exactly the live binary the authority had just refused
82
- // (a real Herdr workspace, created from a test). No fallback in this file may
83
- // re-decide the binary: the only remaining branch is for a lib that is missing
84
- // entirely, and it fails closed under the harness.
85
- function herdrBinary() {
86
- let git = null;
87
- try {
88
- git = libRequire("worktree-git.js");
89
- } catch {
90
- git = null;
91
- }
92
- if (git && typeof git.resolveHerdrBinary === "function") {
93
- return git.resolveHerdrBinary(process.env);
94
- }
95
- const harness = process.env.GENTLE_WORKTREE_LIB_DIR;
96
- if (typeof harness === "string" && harness.length > 0) {
97
- throw new Error(
98
- "refusing to run live herdr from a test harness: herdr binary cannot be resolved "
99
- + "(lib/worktree-git.js is unavailable, so no binary is injected)",
100
- );
101
- }
102
- const bin = process.env.HERDR_BIN_PATH;
103
- if (typeof bin === "string" && bin.length > 0) return bin;
104
- return "herdr";
105
- }
106
-
107
- // The texts the authority (and the missing-lib branch above) use to refuse.
108
- // Matching on text keeps the boundary between lib and extension small: a
109
- // refusal is a resolved STATE, not an error class shared across it.
110
- const HERDR_REFUSAL_PATTERN = /refusing to run live herdr|herdr binary is not injected/;
111
-
112
- function isHerdrRefusal(err) {
113
- return !!(
114
- err
115
- && typeof err.message === "string"
116
- && HERDR_REFUSAL_PATTERN.test(err.message)
117
- );
118
- }
119
-
120
71
  function toolCwd(ctx) {
121
72
  if (ctx && typeof ctx.cwd === "string" && ctx.cwd.length > 0) return ctx.cwd;
122
73
  return process.cwd();
@@ -543,12 +494,6 @@ function withWorktreeCardRenderer(tool) {
543
494
  // Model text and visual cards are independent presentation layers. Keep
544
495
  // backend helpers unchanged so runtime callers retain their raw contract.
545
496
  //
546
- // The same seam turns a herdr resolution REFUSAL into an error result: the
547
- // guard's refusal (a test harness handing over a possible live binary, or a
548
- // harness with no binary injected) says the resolution failed, not that the
549
- // tool crashed. Letting it escape would be an uncaught throw out of a tool
550
- // handler, so every refusal is answered with its own text and `isError`, and
551
- // every other exception is re-thrown untouched.
552
497
  const execute = tool.execute;
553
498
  let format = null;
554
499
  try {
@@ -559,13 +504,7 @@ function withWorktreeCardRenderer(tool) {
559
504
  }
560
505
  if (typeof execute === "function") {
561
506
  tool.execute = async function (...args) {
562
- let result;
563
- try {
564
- result = await execute.apply(this, args);
565
- } catch (err) {
566
- if (!isHerdrRefusal(err)) throw err;
567
- result = errorResult(`${tool.name}: ${err.message}`, { tool: tool.name, herdrRefused: true });
568
- }
507
+ const result = await execute.apply(this, args);
569
508
  if (!format) return result;
570
509
  try {
571
510
  return format(tool.name, result);
@@ -703,7 +642,7 @@ async function awaitPendingCreationSetups() {
703
642
  }
704
643
  }
705
644
 
706
- // Core of `worktree_create`: validation (S2, S3) then herdr-first creation
645
+ // Core of `worktree_create`: validation (S2, S3) then git creation
707
646
  // with a git fallback. Exported for unit tests; the registered tool calls
708
647
  // it with the live Pi context. It only ever CREATES: the branch plus the
709
648
  // `changes` selection (S3/S4), and no issue or ODD association is verified,
@@ -711,15 +650,6 @@ async function awaitPendingCreationSetups() {
711
650
  async function executeWorktreeCreate(params, ctx, opts) {
712
651
  const git = libRequire("worktree-git.js");
713
652
  const registry = libRequire("worktree-registry.js");
714
- // S53 git-only route (create-from-warp): the wizard creates a plain
715
- // git checkout plus the registry row and nothing else — never a Herdr
716
- // workspace (`herdr worktree create/open` is what materialized the
717
- // stray Herdr worktree the user never asked for). The route reuses
718
- // every validation and the registry machinery below; only the
719
- // backends change. Subprocesses run through the async runner here so
720
- // a hanging child aborts on its bound instead of freezing the
721
- // interactive session on the main thread.
722
- const gitOnly = !!(opts && opts.gitOnly === true);
723
653
  // `backgroundHooks` is the INTERACTIVE create's speed contract: the checkout
724
654
  // plus its registry row are all the warp needs, so the slow half of the hooks
725
655
  // (install, codegraph) is handed to a tracked background task instead of
@@ -1112,24 +1042,12 @@ async function executeWorktreeCreate(params, ctx, opts) {
1112
1042
  // EXISTING branch with `git worktree add <target> <branch>` — no `-b`,
1113
1043
  // no base, the ref never created, rewritten or deleted — and the result
1114
1044
  // reports `branchCreated: false` plus a warning naming the adopted
1115
- // branch. Herdr's `worktree create` cannot adopt an existing branch, so
1116
- // the adopt case goes through git and then associates the Herdr
1117
- // workspace BEST-EFFORT via `herdr worktree open --path <path>
1118
- // --cwd <root>`; the checkout succeeding never depends on herdr.
1119
- // When the ref does not exist, creation keeps the herdr-first path
1120
- // with a `git worktree add -b` fallback and reports `branchCreated:
1121
- // true`. Fixed argv only, values as separate elements (herdr rejects
1122
- // --flag=value). Never --focus, never --trust-repository: trusting a
1123
- // repository is a human decision these tools never take. The base is
1124
- // LOCAL MAIN (S2), resolved in (d1) before anything was written: a session
1125
- // working inside a linked worktree can never seed the new branch with its
1126
- // own unmerged commits, and a repository without a local `main` was already
1127
- // refused there instead of falling back to the invoking HEAD.
1045
+ // branch. New branches start at local main; existing branches are adopted
1046
+ // unchanged. Both tool and panel creation use git only.
1128
1047
  const base = baseInfo !== null ? baseInfo.commit : null;
1129
1048
  let createdBy = null;
1130
1049
  let createError = null;
1131
1050
  let branchCreated = null;
1132
- let herdrAssociated = null;
1133
1051
  const adoptWarnings = [];
1134
1052
  if (branchExists) {
1135
1053
  // Adopt: check out the existing branch at the fresh path. No `-b`
@@ -1142,45 +1060,9 @@ async function executeWorktreeCreate(params, ctx, opts) {
1142
1060
  adoptWarnings.push(
1143
1061
  `adopted existing branch '${branch}' at ${target}: the worktree checks out that branch as-is, the branch ref was left untouched`,
1144
1062
  );
1145
- // Best-effort Herdr workspace association: reported, never fatal.
1146
- // Skipped on the git-only route: that checkout is git plus the
1147
- // registry row by design, so no association is expected.
1148
- if (!gitOnly) {
1149
- try {
1150
- const opened = await runGit(herdrBinary(), git.herdrArgv("open", { cwd: repo.root, path: target }), {
1151
- cwd: repo.root,
1152
- timeoutMs: 60000,
1153
- });
1154
- herdrAssociated = opened.status === 0;
1155
- } catch (err) {
1156
- herdrAssociated = false;
1157
- }
1158
- if (herdrAssociated !== true) {
1159
- adoptWarnings.push(
1160
- `herdr workspace association failed for ${target} ('herdr worktree open' did not succeed): the checkout itself is fine, associate it by hand when needed`,
1161
- );
1162
- }
1163
- }
1164
1063
  } else {
1165
1064
  createError = ((adopted.stderr || adopted.error || "unknown error") + "").trim();
1166
1065
  }
1167
- } else {
1168
- // The git-only route skips the herdr-first attempt outright: no
1169
- // `herdr worktree create` runs, so no Herdr workspace is ever
1170
- // materialized from the wizard. The tool path keeps herdr-first
1171
- // with the git fallback below, byte-identical.
1172
- let herdrRes = null;
1173
- if (!gitOnly) {
1174
- const createParams = { cwd: repo.root, branch, path: target };
1175
- if (base) createParams.base = base;
1176
- herdrRes = await runGit(herdrBinary(), git.herdrArgv("create", createParams), {
1177
- cwd: repo.root,
1178
- timeoutMs: 120000,
1179
- });
1180
- }
1181
- if (herdrRes !== null && herdrRes.status === 0) {
1182
- createdBy = "herdr";
1183
- branchCreated = true;
1184
1066
  } else {
1185
1067
  const fallbackArgv = ["worktree", "add", "-b", branch, target];
1186
1068
  if (base) fallbackArgv.push(base);
@@ -1212,10 +1094,9 @@ async function executeWorktreeCreate(params, ctx, opts) {
1212
1094
  }
1213
1095
  }
1214
1096
  }
1215
- }
1216
1097
  if (!createdBy) {
1217
1098
  return errorResult(
1218
- `failed to create worktree for branch '${branch}': ${createError || "herdr and git both failed"}`,
1099
+ `failed to create worktree for branch '${branch}': ${createError || "git failed"}`,
1219
1100
  { branch, path: target, pruned: prunedStale },
1220
1101
  );
1221
1102
  }
@@ -1519,11 +1400,8 @@ async function executeWorktreeCreate(params, ctx, opts) {
1519
1400
  // S6: the ONE CodeGraph fact of this create, read from the hooks that just
1520
1401
  // ran (never re-run, never inferred).
1521
1402
  const codegraph = codegraphStatus(hooks);
1522
- // S20 adoption reporting: whether the branch was created or adopted,
1523
- // and (adopt case only) whether the best-effort Herdr workspace
1524
- // association happened. The checkout succeeding never depends on herdr.
1403
+ // Report whether the branch was created or adopted.
1525
1404
  const adoptionDetails = { branchCreated };
1526
- if (herdrAssociated !== null) adoptionDetails.herdrAssociated = herdrAssociated;
1527
1405
  // (l) T41/S50 changes registration: register the checkout just
1528
1406
  // created on the CURRENT session through gentle-shell's
1529
1407
  // `session_worktree_register` host tool, but ONLY when that tool is
@@ -3787,6 +3665,17 @@ async function executeWorktreeWarpTool(params, ctx, opts) {
3787
3665
  // `fromName`/`name`/`path`/`branch`), so the card falls back to that line
3788
3666
  // instead of painting a move that has not happened. The one visible "moved"
3789
3667
  // card comes from the switched-to session once the move is real.
3668
+ // S4: this session's own background work is a refusal, not a queue. The
3669
+ // command that performs the move waits for idle — and a session with running
3670
+ // agent tasks IS idle — so queueing here would move the session out from
3671
+ // under work that is still running. Nothing is queued when this refuses.
3672
+ const toolBusy = await warpSessionActivity(ctx);
3673
+ if (toolBusy && toolBusy.busy === true) {
3674
+ return errorResult(`worktree warp: ${warpBusyText(toolBusy)}`, {
3675
+ tool: "worktree_warp", name: displayName, path: targetCwd, branch: displayBranch,
3676
+ kind: resolved.kind, busy: true,
3677
+ });
3678
+ }
3790
3679
  const handoffDispatched = dispatchWarpHandoff(prepareWarpHandoff({
3791
3680
  name: displayName,
3792
3681
  target: targetCwd,
@@ -4203,6 +4092,62 @@ async function sessionManagerClassFor(ctx) {
4203
4092
  return null;
4204
4093
  }
4205
4094
 
4095
+ // S4: THIS session's own agent-task counters, read from the presence store the
4096
+ // close guard already consumes (lib/worktree-warp-busy.js). Best effort: an
4097
+ // absent module or store answers null and blocks nothing. Background jobs are
4098
+ // NOT covered — gentle-shell keeps them in memory and publishes no durable
4099
+ // evidence — so nothing here claims to see one.
4100
+ async function warpSessionActivity(ctx) {
4101
+ let lib = null;
4102
+ try {
4103
+ lib = libRequire("worktree-warp-busy.js");
4104
+ } catch {
4105
+ return null;
4106
+ }
4107
+ if (!lib || typeof lib.sessionActivity !== "function") return null;
4108
+ const sessionId = closeSessionString(ctx, "getSessionId");
4109
+ try {
4110
+ return await lib.sessionActivity({ sessionId });
4111
+ } catch {
4112
+ return null;
4113
+ }
4114
+ }
4115
+
4116
+ // The one refusal sentence, from the same module that produced the counters. A
4117
+ // missing module falls back to a shorter sentence that still names agent tasks
4118
+ // only.
4119
+ function warpBusyText(activity) {
4120
+ try {
4121
+ const lib = libRequire("worktree-warp-busy.js");
4122
+ if (lib && typeof lib.busyText === "function") return lib.busyText(activity);
4123
+ } catch {
4124
+ // fall through to the fixed sentence
4125
+ }
4126
+ return "this session still has running agent tasks: the warp waits for them to finish (as of the last published heartbeat); background jobs are not visible here, so confirm they finished too";
4127
+ }
4128
+
4129
+ // The COMMAND front's bounded wait: a warp lands after this turn settles, so it
4130
+ // gives the published counters a chance to drain before it refuses. Null when
4131
+ // the module or the session identity is unavailable (best effort, blocks
4132
+ // nothing).
4133
+ async function warpWaitForIdleSession(ctx) {
4134
+ let lib = null;
4135
+ try {
4136
+ lib = libRequire("worktree-warp-busy.js");
4137
+ } catch {
4138
+ return null;
4139
+ }
4140
+ if (!lib || typeof lib.waitForSessionActivity !== "function") return null;
4141
+ const sessionId = closeSessionString(ctx, "getSessionId");
4142
+ try {
4143
+ // The budget travels through the environment so a session can bound its own
4144
+ // wait (ODD_WORKTREE_WARP_BUSY_WAIT_MS); the default keeps a warp short.
4145
+ return await lib.waitForSessionActivity({ sessionId, env: process.env });
4146
+ } catch {
4147
+ return null;
4148
+ }
4149
+ }
4150
+
4206
4151
  function warpFail(text, details) {
4207
4152
  return { text, details: details || {}, isError: true };
4208
4153
  }
@@ -4244,6 +4189,16 @@ function warpNoticeText(targetCwd, branch) {
4244
4189
  return `Worktree warp: now in ${to} (branch '${cleanBranch}').`;
4245
4190
  }
4246
4191
 
4192
+ // S1 (warp-empty-session): what an EMPTY session's warp reports. It names
4193
+ // WHERE the session now is and never a move: nothing was carried, so the
4194
+ // `moved:`/`to:` compact — and the card that accompanies it — would claim a
4195
+ // transition that did not happen.
4196
+ function warpEmptyText(name, targetCwd) {
4197
+ const toName = warpClean(name, 'worktree');
4198
+ const to = warpClean(targetCwd, '(unknown checkout)');
4199
+ return `worktree warp: ${toName} (empty session, nothing to carry; now at ${to})`;
4200
+ }
4201
+
4247
4202
  // Deepest live checkout containing a cwd, by resolved identity — the one
4248
4203
  // rule the listing, the selector title and the already-there check share.
4249
4204
  // `extraIds` lets the already-there check keep its target-cwd candidate
@@ -4535,6 +4490,17 @@ async function executeWorktreeWarp(rawName, ctx, opts) {
4535
4490
  );
4536
4491
  }
4537
4492
  await ctx.waitForIdle();
4493
+ // S4: a session with running agent tasks is IDLE for the host, so the wait
4494
+ // above does not wait for that work. This front gives the published counters
4495
+ // a bounded chance to drain and then refuses instead of moving the session
4496
+ // out from under a task that is still running. A store that is absent or
4497
+ // unreadable blocks nothing.
4498
+ const busyWait = await warpWaitForIdleSession(ctx);
4499
+ if (busyWait && busyWait.activity && busyWait.activity.busy === true) {
4500
+ return warpFail(`worktree warp: ${warpBusyText(busyWait.activity)}`, {
4501
+ name, alreadyThere: false, cancelled: false, busy: true,
4502
+ });
4503
+ }
4538
4504
  const fromCwd = typeof ctx.cwd === "string" && ctx.cwd.length > 0 ? ctx.cwd : process.cwd();
4539
4505
  // S21: the same awaited preflight reads as the tool, so a slow repository
4540
4506
  // never freezes the session that is being moved.
@@ -4813,6 +4779,18 @@ async function executeWorktreeWarp(rawName, ctx, opts) {
4813
4779
  }
4814
4780
  if (handoff) await warpConfirm(r, warpHandoffNotice, true);
4815
4781
  };
4782
+ // S1/S3 (warp-empty-session): the switched-to session of an EMPTY warp is
4783
+ // told nothing — no card, and no handoff continuation either, because the
4784
+ // session carries no task yet (the already-there no-op answers a handoff
4785
+ // with text only, for the same reason). The status line stays: it says
4786
+ // WHERE the session is, not what it carried.
4787
+ const warpSilentWithSession = async (r) => {
4788
+ try {
4789
+ r.ui.setStatus("worktree", `${displayName} (${targetCwd})`);
4790
+ } catch {
4791
+ // Status is cosmetic; the silence is the point.
4792
+ }
4793
+ };
4816
4794
  // S43 records. The POINT of a session is its ACTIVE branch (the live
4817
4795
  // leaf's parentId path, never all append-only entries) filtered to
4818
4796
  // entries that are NOT our own bookkeeping — the custom marker, the
@@ -4855,6 +4833,16 @@ async function executeWorktreeWarp(rawName, ctx, opts) {
4855
4833
  const currentPoint = warpSessions.sessionPoint(
4856
4834
  currentEntriesList, typeof currentLeaf === "string" ? currentLeaf : undefined,
4857
4835
  );
4836
+ // S1/S5 (warp-empty-session): an EMPTY session is one whose active branch
4837
+ // carries no conversation — no `message` entry with role user or assistant
4838
+ // outside the warp bookkeeping, so a rename, the session-worktree
4839
+ // registration or a transition marker never counts as content. It has
4840
+ // nothing to carry, so its warp changes PATH instead of moving work: never
4841
+ // a fork (the host refuses a conversation-less source), never a card, never
4842
+ // a "moved:" claim.
4843
+ const sessionEmpty = !warpSessions.hasConversation(
4844
+ currentEntriesList, typeof currentLeaf === "string" ? currentLeaf : undefined,
4845
+ );
4858
4846
  const warpFileOpen = options && typeof options.warpSessionFileOpen === "function"
4859
4847
  ? options.warpSessionFileOpen
4860
4848
  : undefined;
@@ -5023,7 +5011,13 @@ async function executeWorktreeWarp(rawName, ctx, opts) {
5023
5011
  // cached fingerprint. An unreadable destination never resumes.
5024
5012
  // Never throws: any comparison failure reads as "fork".
5025
5013
  const freshDestPoint = warpFreshPoint(recorded.sessionFile);
5026
- if (freshDestPoint !== null && warpSessions.samePoint(currentPoint, freshDestPoint)) {
5014
+ // An empty source has no point to compare (its own point is empty
5015
+ // by definition), so it RESUMES the destination session whenever
5016
+ // that file is readable — the conversation it already holds (S4)
5017
+ // or its own emptiness — instead of allocating a copy of nothing. A
5018
+ // session with conversation keeps the same-point gate.
5019
+ if (freshDestPoint !== null
5020
+ && (sessionEmpty || warpSessions.samePoint(currentPoint, freshDestPoint))) {
5027
5021
  recordedSession = recorded;
5028
5022
  recordedFreshPoint = freshDestPoint;
5029
5023
  }
@@ -5039,7 +5033,9 @@ async function executeWorktreeWarp(rawName, ctx, opts) {
5039
5033
  if (recordedSessionFile !== null) {
5040
5034
  let resumed;
5041
5035
  try {
5042
- resumed = await ctx.switchSession(recordedSessionFile, { withSession: warpResumeWithSession });
5036
+ resumed = await ctx.switchSession(recordedSessionFile, {
5037
+ withSession: sessionEmpty ? warpSilentWithSession : warpResumeWithSession,
5038
+ });
5043
5039
  } catch (err) {
5044
5040
  return failAfterCopy(
5045
5041
  `worktree warp: the session switch to ${targetCwd} failed: ${(err && err.message) || err}; the recorded warp session was kept`,
@@ -5066,8 +5062,12 @@ async function executeWorktreeWarp(rawName, ctx, opts) {
5066
5062
  );
5067
5063
  emitOsc7(options.oscStream || process.stdout, targetCwd);
5068
5064
  return {
5069
- text: moveText,
5070
- details: { name: displayName, path: targetCwd, branch: displayBranch, kind: resolved.kind, alreadyThere: false, cancelled: false, ...handoffCopyDetails() },
5065
+ text: sessionEmpty ? warpEmptyText(name, targetCwd) : moveText,
5066
+ details: {
5067
+ name: displayName, path: targetCwd, branch: displayBranch, kind: resolved.kind,
5068
+ alreadyThere: false, cancelled: false,
5069
+ ...(sessionEmpty ? { emptySession: true } : {}), ...handoffCopyDetails(),
5070
+ },
5071
5071
  };
5072
5072
  }
5073
5073
  const sm = ctx.sessionManager || {};
@@ -5082,7 +5082,11 @@ async function executeWorktreeWarp(rawName, ctx, opts) {
5082
5082
  const leaf = typeof sm.getLeafId === "function" ? safe(() => sm.getLeafId()) : null;
5083
5083
  const sessionDir = typeof sm.getSessionDir === "function" ? safe(() => sm.getSessionDir()) : undefined;
5084
5084
  let carried = [];
5085
- if (!warpSourceUsable(file, warpFileOpen)) {
5085
+ // S1: an empty session is allowed through without a persisted file or
5086
+ // in-memory entries — the refusal below exists because a session with
5087
+ // nothing to carry had nothing to warp FOR, and the empty path now changes
5088
+ // its path instead of carrying anything.
5089
+ if (!sessionEmpty && !warpSourceUsable(file, warpFileOpen)) {
5086
5090
  const entries = typeof sm.getEntries === "function" ? safe(() => sm.getEntries()) : undefined;
5087
5091
  carried = Array.isArray(entries) ? entries : [];
5088
5092
  if (carried.length === 0) {
@@ -5093,6 +5097,82 @@ async function executeWorktreeWarp(rawName, ctx, opts) {
5093
5097
  }
5094
5098
  }
5095
5099
  const SM = options.sessionManagerClass || await sessionManagerClassFor(ctx);
5100
+ // S3 (warp-empty-session): an empty session changes PATH. There is nothing
5101
+ // to carry, so nothing is forked (`forkFrom` refuses a conversation-less
5102
+ // source anyway) and the in-place move the host would need does not exist —
5103
+ // `switchSession(file)` is the only mount, and a session's cwd comes from
5104
+ // its own header. So the equivalent is used: a FRESH session allocated at
5105
+ // the target cwd through the same create/switch machinery the non-persisted
5106
+ // path uses, with only its header written (`wx`, never clobbering) so the
5107
+ // mount and the record both have a real file. The switch is silent: no
5108
+ // card, no transition entry, no rename — bookkeeping this flow did not earn.
5109
+ if (sessionEmpty) {
5110
+ if (!SM || typeof SM.create !== "function") {
5111
+ return failAfterCopy(
5112
+ "worktree warp is unavailable here: the host SessionManager has no create",
5113
+ { name, path: targetCwd, branch: displayBranch, kind: resolved.kind, alreadyThere: false, cancelled: false },
5114
+ );
5115
+ }
5116
+ let emptyFile = null;
5117
+ try {
5118
+ const allocated = SM.create(targetCwd, sessionDir);
5119
+ const allocatedFile = allocated && typeof allocated.getSessionFile === "function" ? allocated.getSessionFile() : undefined;
5120
+ if (typeof allocatedFile !== "string" || allocatedFile.length === 0) {
5121
+ throw new Error("the host allocated no session file for the target");
5122
+ }
5123
+ const header = typeof allocated.getHeader === "function" ? allocated.getHeader() : null;
5124
+ if (!header) throw new Error("the allocated session has no header");
5125
+ // The file is the header alone: there is no entry to carry, and a file
5126
+ // naming the target cwd is what makes the mount and the fresh point
5127
+ // real. `wx` never clobbers an existing file.
5128
+ fs.writeFileSync(allocatedFile, `${JSON.stringify(header)}\n`, { flag: "wx" });
5129
+ emptyFile = allocatedFile;
5130
+ } catch (err) {
5131
+ return failAfterCopy(
5132
+ `worktree warp: could not change this session's path to ${targetCwd}: ${(err && err.message) || err}`,
5133
+ { name, path: targetCwd, branch: displayBranch, kind: resolved.kind, alreadyThere: false, cancelled: false },
5134
+ );
5135
+ }
5136
+ let changed;
5137
+ try {
5138
+ changed = await ctx.switchSession(emptyFile, { withSession: warpSilentWithSession });
5139
+ } catch (err) {
5140
+ return failAfterCopy(
5141
+ `worktree warp: the session switch to ${targetCwd} failed: ${(err && err.message) || err}; the new session file was kept at ${emptyFile}`,
5142
+ { name, path: targetCwd, branch: displayBranch, kind: resolved.kind, alreadyThere: false, cancelled: false },
5143
+ );
5144
+ }
5145
+ if (changed && changed.cancelled === true) {
5146
+ try {
5147
+ fs.unlinkSync(emptyFile);
5148
+ } catch {
5149
+ // Best effort: the report below is what matters.
5150
+ }
5151
+ return failAfterCopy(
5152
+ `worktree warp: ${name} cancelled (the session stays in ${fromCwd}; the new session file was removed)`,
5153
+ { name, path: targetCwd, branch: displayBranch, kind: resolved.kind, alreadyThere: false, cancelled: true },
5154
+ );
5155
+ }
5156
+ // Recording is unchanged: both sides are recorded at their own points, so
5157
+ // a later warp finds this destination session again (S2: warping back
5158
+ // from an empty session is the same empty case).
5159
+ try {
5160
+ recordBothSides(
5161
+ targetCwd, emptyFile, warpFreshPoint(emptyFile),
5162
+ sourceIdentity, currentFile, currentPoint,
5163
+ );
5164
+ } catch {
5165
+ // Best effort: the warp already switched.
5166
+ }
5167
+ emitOsc7(options.oscStream || process.stdout, targetCwd);
5168
+ return {
5169
+ text: warpEmptyText(name, targetCwd),
5170
+ details: {
5171
+ name: displayName, path: targetCwd, branch: displayBranch, kind: resolved.kind,
5172
+ alreadyThere: false, cancelled: false, emptySession: true, ...handoffCopyDetails(),
5173
+ },
5174
+ };
5175
+ }
5096
5176
  if (!SM || typeof SM.forkFrom !== "function") {
5097
5177
  return failAfterCopy(
5098
5178
  "worktree warp is unavailable here: the host SessionManager has no forkFrom",
@@ -5851,8 +5931,8 @@ async function warpCreateAndEnter(ctx, git, notify) {
5851
5931
  return { closed: true, created: false, cancelled: true };
5852
5932
  }
5853
5933
  createParams.changes = pending.copy ? 'all' : 'none';
5854
- // S53: the git-only route — a plain git checkout plus the registry row,
5855
- // never a Herdr workspace — reusing the same creation path (validations,
5934
+ // S53: the panel's create — a plain git checkout plus the registry row —
5935
+ // reusing the same creation path (validations,
5856
5936
  // refusals, registration), which never prompts. The layout is
5857
5937
  // principal-anchored (S56): the new checkout lands beside the PRINCIPAL
5858
5938
  // checkout even when the panel runs inside a linked worktree, while the
@@ -5865,7 +5945,6 @@ async function warpCreateAndEnter(ctx, git, notify) {
5865
5945
  // can move, or the human would switch into a checkout that is missing the
5866
5946
  // work they just asked to carry.
5867
5947
  const created = await executeWorktreeCreate(createParams, ctx, {
5868
- gitOnly: true,
5869
5948
  principalLayout: true,
5870
5949
  backgroundHooks: true,
5871
5950
  ...(typeof notify === 'function' ? { notify } : {}),