pi-odd-worktree 1.1.1 → 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 +19 -2
- package/extensions/odd-worktree-tools.ts +87 -132
- 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/package.json +1 -1
- package/tools/herdr-free-create.test.js +161 -0
- package/tools/warp-busy-guard.test.js +488 -0
- package/tools/worktree-tools.test.js +24 -0
package/README.md
CHANGED
|
@@ -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
|
}
|
|
@@ -4545,6 +4490,17 @@ async function executeWorktreeWarp(rawName, ctx, opts) {
|
|
|
4545
4490
|
);
|
|
4546
4491
|
}
|
|
4547
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
|
+
}
|
|
4548
4504
|
const fromCwd = typeof ctx.cwd === "string" && ctx.cwd.length > 0 ? ctx.cwd : process.cwd();
|
|
4549
4505
|
// S21: the same awaited preflight reads as the tool, so a slow repository
|
|
4550
4506
|
// never freezes the session that is being moved.
|
|
@@ -5975,8 +5931,8 @@ async function warpCreateAndEnter(ctx, git, notify) {
|
|
|
5975
5931
|
return { closed: true, created: false, cancelled: true };
|
|
5976
5932
|
}
|
|
5977
5933
|
createParams.changes = pending.copy ? 'all' : 'none';
|
|
5978
|
-
// S53: the
|
|
5979
|
-
//
|
|
5934
|
+
// S53: the panel's create — a plain git checkout plus the registry row —
|
|
5935
|
+
// reusing the same creation path (validations,
|
|
5980
5936
|
// refusals, registration), which never prompts. The layout is
|
|
5981
5937
|
// principal-anchored (S56): the new checkout lands beside the PRINCIPAL
|
|
5982
5938
|
// checkout even when the panel runs inside a linked worktree, while the
|
|
@@ -5989,7 +5945,6 @@ async function warpCreateAndEnter(ctx, git, notify) {
|
|
|
5989
5945
|
// can move, or the human would switch into a checkout that is missing the
|
|
5990
5946
|
// work they just asked to carry.
|
|
5991
5947
|
const created = await executeWorktreeCreate(createParams, ctx, {
|
|
5992
|
-
gitOnly: true,
|
|
5993
5948
|
principalLayout: true,
|
|
5994
5949
|
backgroundHooks: true,
|
|
5995
5950
|
...(typeof notify === 'function' ? { notify } : {}),
|
package/lib/worktree-git.js
CHANGED
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Every external process is spawned with a fixed argv array — no shell
|
|
5
5
|
// string is ever built, so branch names and paths cannot inject commands.
|
|
6
|
-
// `listWorktrees` parses `git worktree list --porcelain -z` itself
|
|
7
|
-
// of trusting Herdr JSON output.
|
|
6
|
+
// `listWorktrees` parses `git worktree list --porcelain -z` itself.
|
|
8
7
|
|
|
9
8
|
const { spawnSync } = require('node:child_process');
|
|
10
9
|
const fs = require('node:fs');
|
|
@@ -564,193 +563,7 @@ function matchesAnyCheckout(identity, resolved, entries) {
|
|
|
564
563
|
return false;
|
|
565
564
|
}
|
|
566
565
|
|
|
567
|
-
// Closed union of herdr operations with a per-operation flag allowlist,
|
|
568
|
-
// mirroring the installed CLI surface (herdr 0.9.4, verified read-only
|
|
569
|
-
// against `herdr worktree list|create|open --help`, `herdr pane list
|
|
570
|
-
// --help` and `herdr agent start|get|list --help`: every flag below
|
|
571
|
-
// exists there with the shape used here). Every value travels as its own
|
|
572
|
-
// argv element because herdr rejects `--flag=value`. Unknown operations
|
|
573
|
-
// or flags throw. `--trust-repository` is deliberately absent everywhere:
|
|
574
|
-
// trusting a repository is a human decision these tools never take.
|
|
575
|
-
// `--focus` stays opt-in for the worktree create/open calls that predate
|
|
576
|
-
// the session tool; the session tool itself only ever passes `--no-focus`.
|
|
577
|
-
const HERDR_ALLOWLIST = {
|
|
578
|
-
list: ['cwd'],
|
|
579
|
-
create: ['cwd', 'branch', 'base', 'path', 'label', 'focus'],
|
|
580
|
-
open: ['cwd', 'path', 'branch', 'noFocus', 'focus'],
|
|
581
|
-
remove: ['workspace', 'force'],
|
|
582
|
-
'pane-list': ['workspace'],
|
|
583
|
-
'agent-start': ['name', 'kind', 'pane', 'timeout', 'agentArgs'],
|
|
584
|
-
'agent-get': ['target'],
|
|
585
|
-
'agent-list': [],
|
|
586
|
-
};
|
|
587
|
-
|
|
588
|
-
const HERDR_FLAG = {
|
|
589
|
-
cwd: '--cwd',
|
|
590
|
-
workspace: '--workspace',
|
|
591
|
-
branch: '--branch',
|
|
592
|
-
base: '--base',
|
|
593
|
-
path: '--path',
|
|
594
|
-
label: '--label',
|
|
595
|
-
focus: '--focus',
|
|
596
|
-
noFocus: '--no-focus',
|
|
597
|
-
force: '--force',
|
|
598
|
-
kind: '--kind',
|
|
599
|
-
pane: '--pane',
|
|
600
|
-
timeout: '--timeout',
|
|
601
|
-
};
|
|
602
566
|
|
|
603
|
-
// Build the fixed argv for a herdr operation. Worktree operations produce
|
|
604
|
-
// `herdr worktree <op> ...`; the session composition additionally needs
|
|
605
|
-
// `herdr pane list`, `herdr agent start <NAME> ... -- [AGENT_ARG]...`,
|
|
606
|
-
// `herdr agent get <target>` and `herdr agent list`, each with its own
|
|
607
|
-
// fixed shape below so no caller hand-builds herdr argv.
|
|
608
|
-
function herdrArgv(operation, params = {}) {
|
|
609
|
-
const allowed = HERDR_ALLOWLIST[operation];
|
|
610
|
-
if (!allowed) throw new Error(`unknown herdr operation: ${operation}`);
|
|
611
|
-
for (const key of Object.keys(params)) {
|
|
612
|
-
if (!allowed.includes(key)) throw new Error(`flag not allowed for herdr ${operation}: ${key}`);
|
|
613
|
-
}
|
|
614
|
-
// `herdr agent start <NAME> --kind <KIND> --pane <ID> --timeout <MS>
|
|
615
|
-
// -- [AGENT_ARG]...`: the agent name is positional and always first,
|
|
616
|
-
// the Pi argv travels after a `--` separator. Fixed order, values as
|
|
617
|
-
// separate elements, never `--flag=value`.
|
|
618
|
-
if (operation === 'agent-start') {
|
|
619
|
-
const { name, kind, pane, timeout, agentArgs } = params;
|
|
620
|
-
if (typeof name !== 'string' || name === '') throw new Error('invalid value for herdr agent-start name');
|
|
621
|
-
for (const [key, value] of [['kind', kind], ['pane', pane], ['timeout', timeout]]) {
|
|
622
|
-
if (typeof value !== 'string' || value === '') throw new Error(`invalid value for herdr agent-start ${key}`);
|
|
623
|
-
}
|
|
624
|
-
if (!Array.isArray(agentArgs) || agentArgs.some((entry) => typeof entry !== 'string')) {
|
|
625
|
-
throw new Error('invalid value for herdr agent-start agentArgs');
|
|
626
|
-
}
|
|
627
|
-
return ['agent', 'start', name,
|
|
628
|
-
'--kind', kind, '--pane', pane, '--timeout', timeout,
|
|
629
|
-
'--', ...agentArgs];
|
|
630
|
-
}
|
|
631
|
-
// `herdr agent get <target>`: the target is positional.
|
|
632
|
-
if (operation === 'agent-get') {
|
|
633
|
-
const { target } = params;
|
|
634
|
-
if (typeof target !== 'string' || target === '') throw new Error('invalid value for herdr agent-get target');
|
|
635
|
-
return ['agent', 'get', target];
|
|
636
|
-
}
|
|
637
|
-
// `herdr agent list` takes no flags.
|
|
638
|
-
if (operation === 'agent-list') return ['agent', 'list'];
|
|
639
|
-
// `herdr pane list [--workspace <ID>]`.
|
|
640
|
-
if (operation === 'pane-list') {
|
|
641
|
-
const argv = ['pane', 'list'];
|
|
642
|
-
if (params.workspace !== undefined) {
|
|
643
|
-
if (typeof params.workspace !== 'string' || params.workspace === '') {
|
|
644
|
-
throw new Error('invalid value for herdr pane-list workspace');
|
|
645
|
-
}
|
|
646
|
-
argv.push('--workspace', params.workspace);
|
|
647
|
-
}
|
|
648
|
-
return argv;
|
|
649
|
-
}
|
|
650
|
-
const argv = ['worktree', operation];
|
|
651
|
-
for (const key of Object.keys(params)) {
|
|
652
|
-
if (!allowed.includes(key)) throw new Error(`flag not allowed for herdr ${operation}: ${key}`);
|
|
653
|
-
const value = params[key];
|
|
654
|
-
if (typeof value === 'boolean') {
|
|
655
|
-
if (value) argv.push(HERDR_FLAG[key]);
|
|
656
|
-
continue;
|
|
657
|
-
}
|
|
658
|
-
if (typeof value !== 'string' || value === '') throw new Error(`invalid value for herdr ${operation} ${key}`);
|
|
659
|
-
argv.push(HERDR_FLAG[key], value);
|
|
660
|
-
}
|
|
661
|
-
return argv;
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
// Where the herdr binary comes from. Production honors the documented
|
|
665
|
-
// HERDR_BIN_PATH override (tests), else `herdr` on PATH — the same
|
|
666
|
-
// convention lib/gentle-diagnostics.js uses. Production (no harness
|
|
667
|
-
// marker) keeps that historical behavior untouched.
|
|
668
|
-
//
|
|
669
|
-
// The test harness (GENTLE_WORKTREE_LIB_DIR, set only by the test loaders
|
|
670
|
-
// that import the extension copy) is resolved FIRST, before the override is
|
|
671
|
-
// trusted: an exported HERDR_BIN_PATH is inherited by every test process, so
|
|
672
|
-
// honoring it first let a test run spawn the developer's LIVE herdr and
|
|
673
|
-
// create real Herdr workspaces. Under the harness, therefore:
|
|
674
|
-
//
|
|
675
|
-
// - no binary at all still fails closed with the same refusal as before;
|
|
676
|
-
// - a binary is honored only when the path itself proves it cannot be the
|
|
677
|
-
// live one (`isProvablyNotLiveHerdr`, which decides from the filesystem and
|
|
678
|
-
// never executes anything);
|
|
679
|
-
// - a possible live herdr is refused unless the CURRENT test opts in with the
|
|
680
|
-
// dedicated flag below. The flag is the test's own declaration, so it must
|
|
681
|
-
// be set deliberately rather than inherited along with the path.
|
|
682
|
-
const LIVE_HERDR_OPT_IN = 'GENTLE_WORKTREE_ALLOW_LIVE_HERDR';
|
|
683
|
-
|
|
684
|
-
function realPathOrNull(target) {
|
|
685
|
-
try {
|
|
686
|
-
return fs.realpathSync(target);
|
|
687
|
-
} catch {
|
|
688
|
-
return null;
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
function isInsideDir(candidate, dir) {
|
|
693
|
-
if (candidate === dir) return true;
|
|
694
|
-
return candidate.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep);
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
// The scratch locations this repository's tests build their stub binaries in.
|
|
698
|
-
// os.tmpdir() is the convention every fixture follows (`fs.mkdtempSync(
|
|
699
|
-
// path.join(os.tmpdir(), ...))`), and no live Herdr install lives there.
|
|
700
|
-
function testScratchRoots() {
|
|
701
|
-
const roots = [];
|
|
702
|
-
for (const candidate of [os.tmpdir(), path.resolve(os.tmpdir())]) {
|
|
703
|
-
const real = realPathOrNull(candidate) || candidate;
|
|
704
|
-
if (!roots.includes(real)) roots.push(real);
|
|
705
|
-
}
|
|
706
|
-
return roots;
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
// Can this path be PROVEN not to be a live herdr? Only filesystem facts are
|
|
710
|
-
// used — the binary is never executed and nothing is spawned, so the answer
|
|
711
|
-
// costs one realpath plus one stat and cannot reach a Herdr session.
|
|
712
|
-
//
|
|
713
|
-
// Proven not live: an absolute path that does not exist (a spawn of it fails),
|
|
714
|
-
// that is not a regular file, that is not executable (a spawn of it fails),
|
|
715
|
-
// or whose realpath is inside the test scratch directory. Everything else —
|
|
716
|
-
// a relative or bare value (PATH/cwd resolved) and an existing executable
|
|
717
|
-
// outside scratch — could be the live binary and is left unproven.
|
|
718
|
-
function isProvablyNotLiveHerdr(bin) {
|
|
719
|
-
if (typeof bin !== 'string' || !path.isAbsolute(bin)) return false;
|
|
720
|
-
const real = realPathOrNull(bin);
|
|
721
|
-
if (real === null) return true;
|
|
722
|
-
let stat;
|
|
723
|
-
try {
|
|
724
|
-
stat = fs.statSync(real);
|
|
725
|
-
} catch {
|
|
726
|
-
return true;
|
|
727
|
-
}
|
|
728
|
-
if (!stat.isFile()) return true;
|
|
729
|
-
try {
|
|
730
|
-
fs.accessSync(real, fs.constants.X_OK);
|
|
731
|
-
} catch {
|
|
732
|
-
return true;
|
|
733
|
-
}
|
|
734
|
-
return testScratchRoots().some((root) => isInsideDir(real, root));
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
function resolveHerdrBinary(env = {}) {
|
|
738
|
-
const source = env || {};
|
|
739
|
-
const bin = source.HERDR_BIN_PATH;
|
|
740
|
-
const injected = typeof bin === 'string' && bin.length > 0;
|
|
741
|
-
const harness = source.GENTLE_WORKTREE_LIB_DIR;
|
|
742
|
-
const inHarness = typeof harness === 'string' && harness.length > 0;
|
|
743
|
-
if (!inHarness) return injected ? bin : 'herdr';
|
|
744
|
-
if (source[LIVE_HERDR_OPT_IN] === '1') return injected ? bin : 'herdr';
|
|
745
|
-
if (!injected) {
|
|
746
|
-
throw new Error('herdr binary is not injected: set HERDR_BIN_PATH in tests (refusing to run live herdr)');
|
|
747
|
-
}
|
|
748
|
-
if (isProvablyNotLiveHerdr(bin)) return bin;
|
|
749
|
-
throw new Error(
|
|
750
|
-
`refusing to run live herdr from a test harness: HERDR_BIN_PATH=${bin} can execute a live herdr; `
|
|
751
|
-
+ `inject a scratch stub inside the OS temp directory, or set ${LIVE_HERDR_OPT_IN}=1 in the test that needs a live session`,
|
|
752
|
-
);
|
|
753
|
-
}
|
|
754
567
|
|
|
755
568
|
// ---------------------------------------------------------------------
|
|
756
569
|
// Commits ahead of / behind a base branch (warp-popup W1).
|
|
@@ -1043,6 +856,4 @@ module.exports = {
|
|
|
1043
856
|
listWorktreesAsync,
|
|
1044
857
|
isPathInAnyCheckout,
|
|
1045
858
|
isPathInAnyCheckoutAsync,
|
|
1046
|
-
herdrArgv,
|
|
1047
|
-
resolveHerdrBinary,
|
|
1048
859
|
};
|
package/lib/worktree-hooks.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// (b) dependency install by lockfile-detected stack,
|
|
9
9
|
// (c) `codegraph init` when the CLI is available.
|
|
10
10
|
//
|
|
11
|
-
// Both `worktree_upsert` (sync runner) and the
|
|
11
|
+
// Both `worktree_upsert` (sync runner) and the panel create path (async
|
|
12
12
|
// twin) reach these hooks, so the link policy is applied by ONE shared
|
|
13
13
|
// implementation in `lib/worktree-symlinks.js` and both creation routes
|
|
14
14
|
// behave identically.
|
|
@@ -394,7 +394,7 @@ async function runCreationHooksAsync(opts = {}) {
|
|
|
394
394
|
|
|
395
395
|
// (a2) Link git-ignored principal entries: the async twin of the sync
|
|
396
396
|
// step, sharing the one classifier in lib/worktree-symlinks.js so the tool
|
|
397
|
-
// path and the
|
|
397
|
+
// path and the panel create path cannot disagree. The git reads and the
|
|
398
398
|
// link writes are awaited, so the event loop keeps running.
|
|
399
399
|
//
|
|
400
400
|
// Policy (round-3 S3/L4): `.agents`, `.pi`, `.claude` and `odd` become
|
|
@@ -362,6 +362,14 @@ async function readGentlePresence(options = {}) {
|
|
|
362
362
|
incarnation: header.incarnation,
|
|
363
363
|
label: header.label,
|
|
364
364
|
heartbeat: header.heartbeat,
|
|
365
|
+
// Published task counters, validated above and passed through
|
|
366
|
+
// unchanged: the warp guard reads this session's own workload.
|
|
367
|
+
counts: {
|
|
368
|
+
running: header.counts.running,
|
|
369
|
+
queued: header.counts.queued,
|
|
370
|
+
waiting: header.counts.waiting,
|
|
371
|
+
finished: header.counts.finished,
|
|
372
|
+
},
|
|
365
373
|
recent: now >= header.heartbeat && now - header.heartbeat <= PRESENCE_FRESH_MS,
|
|
366
374
|
workspace,
|
|
367
375
|
unknown: workspace === null,
|