Before submitting
Problem
subagent_run declares workspace_root and repository_root as optional, but its guard rejects the call when both keys are present, before validating their values:
if (Object.hasOwn(params, "repository_root") && Object.hasOwn(params, "workspace_root")) throw new Error("repository_root and workspace_root are mutually exclusive.");
A caller or host that materializes optional string arguments as empty strings therefore sends both keys with "". The guard reads that as two destinations, throws, and the launch never happens — even though only one destination was actually named and the other value is blank.
In one full working session this blocked every delegation: more than ten subagent_run calls failed with the same error and no subagent was ever queued or spawned, so no delegated work was possible at all. The error message is also misleading, because the failing call did not name two roots.
Steps to reproduce
- From a session whose working directory is inside a Git worktree, call
subagent_run with a valid agent and task.
- Pass
workspace_root with a canonical worktree of the same clone, and pass repository_root as an empty string (the same happens when both are empty strings).
- Observe the error.
Expected and actual behavior
Expected: a blank selector names no destination, so the call proceeds — and two real destinations are still rejected.
Actual: repository_root and workspace_root are mutually exclusive. is thrown before the string-type checks; nothing is queued, nothing is spawned, and no task id is returned.
Proposed fix
Treat a blank selector as naming no destination: normalize it to absent, and only reject two real roots. This cannot widen access — a blank value can never select a path — so mutual exclusion for real destinations, and the interactive consent path for a foreign repository, both stay intact.
--- a/extensions/gentle-agents.ts
+++ b/extensions/gentle-agents.ts
@@ -1238,7 +1238,11 @@
async (params, ctx, signal) => {
- if (Object.hasOwn(params, "repository_root") && Object.hasOwn(params, "workspace_root")) throw new Error("repository_root and workspace_root are mutually exclusive.");
+ // An empty selector names no destination: only real roots are mutually
+ // exclusive, so a blank string must not masquerade as a second target.
+ const hasWorkspaceRoot = Object.hasOwn(params, "workspace_root") && params.workspace_root !== "";
+ const hasRepositoryRoot = Object.hasOwn(params, "repository_root") && params.repository_root !== "";
+ if (hasRepositoryRoot && hasWorkspaceRoot) throw new Error("repository_root and workspace_root are mutually exclusive.");
if ((Object.hasOwn(params, "repository_root") && typeof params.repository_root !== "string") || (Object.hasOwn(params, "workspace_root") && typeof params.workspace_root !== "string")) throw new Error("Root selectors must be strings.");
@@ -1249,7 +1253,9 @@
- return launch(ctx, await buildRequest(ctx, agent, String(params.task ?? ""), typeof params.label === "string" ? params.label : undefined, typeof params.context === "string" ? params.context : undefined, mode, undefined, typeof params.workspace_root === "string" ? params.workspace_root : undefined, signal, typeof params.repository_root === "string" ? params.repository_root : undefined), signal);
+ const workspaceRoot = typeof params.workspace_root === "string" && params.workspace_root !== "" ? params.workspace_root : undefined;
+ const repositoryRoot = typeof params.repository_root === "string" && params.repository_root !== "" ? params.repository_root : undefined;
+ return launch(ctx, await buildRequest(ctx, agent, String(params.task ?? ""), typeof params.label === "string" ? params.label : undefined, typeof params.context === "string" ? params.context : undefined, mode, undefined, workspaceRoot, signal, repositoryRoot), signal);
The patch also adds one regression assertion to the existing foreign clone tool requires consent before queueing and never enters parent Changes test: a call with blank selectors passes the exclusivity guard, reaches agent lookup, and spawns nothing and registers no worktree.
Verification I ran locally on a source checkout:
node --experimental-strip-types --test tests/gentle-agents.test.ts — the guard tests pass, including foreign clone tool requires consent before queueing and never enters parent Changes and foreign clone rejects aliases, absent UI, decline and changed session before any child starts.
- The same file reports 8 pre-existing failures in that local checkout (
owned child diff relay ..., a mutation dropped for ..., default Node spawn adapter distinguishes IPC-only and permission-capable canonical Git children). I reproduced all 8 in a pristine worktree at the same commit without the patch, so they are unrelated to this change and are not part of this report.
gentle-pi version
3.7.0 — source checkout at b50b417c, compared against upstream main at 42d9676 (the five commits between them do not touch extensions/gentle-agents.ts or tests/gentle-agents.test.ts, so the patch applies cleanly to main).
Pi version
0.87.1
Operating system
Windows
Relevant logs or error output
Error: repository_root and workspace_root are mutually exclusive.
The same error was observed with both selectors blank, which is the clearest form of the problem: the call named no destination and still could not launch.
Before submitting
Problem
subagent_rundeclaresworkspace_rootandrepository_rootas optional, but its guard rejects the call when both keys are present, before validating their values:A caller or host that materializes optional string arguments as empty strings therefore sends both keys with
"". The guard reads that as two destinations, throws, and the launch never happens — even though only one destination was actually named and the other value is blank.In one full working session this blocked every delegation: more than ten
subagent_runcalls failed with the same error and no subagent was ever queued or spawned, so no delegated work was possible at all. The error message is also misleading, because the failing call did not name two roots.Steps to reproduce
subagent_runwith a validagentandtask.workspace_rootwith a canonical worktree of the same clone, and passrepository_rootas an empty string (the same happens when both are empty strings).{ "agent": "explore", "task": "Report the current directory", "workspace_root": "<canonical worktree of the same clone>", "repository_root": "" }Expected and actual behavior
Expected: a blank selector names no destination, so the call proceeds — and two real destinations are still rejected.
Actual:
repository_root and workspace_root are mutually exclusive.is thrown before the string-type checks; nothing is queued, nothing is spawned, and no task id is returned.Proposed fix
Treat a blank selector as naming no destination: normalize it to absent, and only reject two real roots. This cannot widen access — a blank value can never select a path — so mutual exclusion for real destinations, and the interactive consent path for a foreign repository, both stay intact.
The patch also adds one regression assertion to the existing
foreign clone tool requires consent before queueing and never enters parent Changestest: a call with blank selectors passes the exclusivity guard, reaches agent lookup, and spawns nothing and registers no worktree.Verification I ran locally on a source checkout:
node --experimental-strip-types --test tests/gentle-agents.test.ts— the guard tests pass, includingforeign clone tool requires consent before queueing and never enters parent Changesandforeign clone rejects aliases, absent UI, decline and changed session before any child starts.owned child diff relay ...,a mutation dropped for ...,default Node spawn adapter distinguishes IPC-only and permission-capable canonical Git children). I reproduced all 8 in a pristine worktree at the same commit without the patch, so they are unrelated to this change and are not part of this report.gentle-pi version
3.7.0 — source checkout at
b50b417c, compared against upstreammainat42d9676(the five commits between them do not touchextensions/gentle-agents.tsortests/gentle-agents.test.ts, so the patch applies cleanly tomain).Pi version
0.87.1
Operating system
Windows
Relevant logs or error output
The same error was observed with both selectors blank, which is the clearest form of the problem: the call named no destination and still could not launch.