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 +20 -3
- package/extensions/odd-worktree-tools.ts +216 -137
- package/lib/worktree-git.js +1 -190
- package/lib/worktree-hooks.js +2 -2
- package/lib/worktree-session-guard.js +8 -0
- package/lib/worktree-warp-busy.js +119 -0
- package/lib/worktree-warp-sessions.js +28 -0
- package/package.json +1 -1
- package/tools/herdr-free-create.test.js +161 -0
- package/tools/warp-busy-guard.test.js +488 -0
- package/tools/warp-empty-session.test.js +495 -0
- package/tools/worktree-tools.test.js +24 -0
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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.
|
|
1116
|
-
//
|
|
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 || "
|
|
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
|
-
//
|
|
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
|
-
|
|
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, {
|
|
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: {
|
|
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
|
-
|
|
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
|
|
5855
|
-
//
|
|
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 } : {}),
|