Problem
The rose lifecycle renderer currently shares extensions/quiet-tools.ts with the non-Bash quiet-tool overrides. When a Pi package filters that extension to preserve another tool owner, it also loses the Gentle AI Bash renderer even though command execution remains available.
The result is a generic Bash row instead of the established lifecycle presentation:
🌹︎ Gentle AI · running|completed|failed · <safe operation path>
Existing context and duplicate search
This issue is a narrow follow-up: preserve the already-established renderer when extensions/quiet-tools.ts is intentionally filtered.
Reproduction
- Load Gentle Pi as a package while filtering
extensions/quiet-tools.ts to avoid replacing another registered tool owner.
- Invoke a recognized direct Gentle AI command through Pi's Bash tool, such as
gentle-ai --version or gentle-ai review status ....
- Observe that the command executes, but the rose lifecycle renderer is unavailable.
Expected behavior
Filtering the non-Bash quiet tools should not disable Gentle AI Bash lifecycle rendering. Bash should have exactly one renderer owner, and the filtered extension must not re-register edit or other quiet tools.
Proposed fix
- Add a standalone
extensions/gentle-ai-bash.ts extension.
- Export dedicated Bash registration from
extensions/quiet-tools.ts.
- Make the default quiet-tools registration skip Bash to prevent duplicate ownership.
- Add focused coverage proving independent Bash loading, preserved edit ownership, and unchanged default registration.
- Load the standalone extension in the runtime harness.
Acceptance criteria
Validation evidence
Prepared locally as commit c8e21ec55 (fix(quiet-tools): load rose renderer independently).
- Targeted renderer behavior: 1 passed, 0 failed.
- Runtime harness: passed using
node --experimental-strip-types tests/runtime-harness.mjs.
git diff --check origin/main: passed.
- Scope: 4 files, 55 insertions, 4 deletions.
- The full quiet-tool test file also collected 47 tests: 46 passed; one unchanged macOS path-alias assertion compared
/tmp with /private/tmp. That unrelated baseline hunk is deliberately excluded from this patch.
Proposed code diff
diff --git a/extensions/gentle-ai-bash.ts b/extensions/gentle-ai-bash.ts
new file mode 100644
index 000000000..95ab92822
--- /dev/null
+++ b/extensions/gentle-ai-bash.ts
@@ -0,0 +1,6 @@
+import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
+import { registerGentleAiBashRenderer } from "./quiet-tools.ts";
+
+export default function gentleAiBash(pi: ExtensionAPI): void {
+ registerGentleAiBashRenderer(pi);
+}
diff --git a/extensions/quiet-tools.ts b/extensions/quiet-tools.ts
index a3a99ba60..1cbaa20ee 100644
--- a/extensions/quiet-tools.ts
+++ b/extensions/quiet-tools.ts
@@ -693,9 +693,17 @@ function registerQuietTool(pi: ExtensionAPI, toolName: QuietToolName, commandArg
});
}
+function registerGentleAiBashRendererWithResolver(pi: ExtensionAPI, resolveOverride: GentleAiDevBinaryOverrideResolver): void {
+ registerQuietTool(pi, "bash", () => createGentleAiCommandArguments(resolveQuietToolsDevBinaryPath(resolveOverride)));
+}
+
+export function registerGentleAiBashRenderer(pi: ExtensionAPI, resolveOverride: GentleAiDevBinaryOverrideResolver = () => resolveGentleAiDevBinaryOverride()): void {
+ registerGentleAiBashRendererWithResolver(pi, resolveOverride);
+}
+
export default function quietTools(pi: ExtensionAPI, resolveOverride: GentleAiDevBinaryOverrideResolver = () => resolveGentleAiDevBinaryOverride()): void {
if (!quietToolsEnabled()) return;
for (const toolName of Object.keys(TOOL_CREATORS) as QuietToolName[]) {
- registerQuietTool(pi, toolName, () => createGentleAiCommandArguments(resolveQuietToolsDevBinaryPath(resolveOverride)));
+ if (toolName !== "bash") registerQuietTool(pi, toolName, () => createGentleAiCommandArguments(resolveQuietToolsDevBinaryPath(resolveOverride)));
}
}
diff --git a/tests/quiet-tool-rendering.test.ts b/tests/quiet-tool-rendering.test.ts
index 6d01b2f11..7df478e54 100644
--- a/tests/quiet-tool-rendering.test.ts
+++ b/tests/quiet-tool-rendering.test.ts
@@ -3,11 +3,13 @@ import test from "node:test";
import { initTheme, keyHint } from "@earendil-works/pi-coding-agent";
import { imageFallback, visibleWidth } from "@earendil-works/pi-tui";
import piPretty from "../extensions/pi-pretty.ts";
-import quietTools, {
+import gentleAiBash from "../extensions/gentle-ai-bash.ts";
+import quietToolsExtension, {
countNonEmptyLines,
extractTextContent,
formatToolResultOutput,
gentleAiRoutineCommand,
+ registerGentleAiBashRenderer,
tailLines,
} from "../extensions/quiet-tools.ts";
@@ -138,15 +140,27 @@ async function withEnvAsync<T>(updates: Record<string, string | undefined>, run:
}
}
+function quietTools(pi: any, resolveOverride?: any): void {
+ quietToolsExtension(pi, resolveOverride);
+ if (process.env.GENTLE_PI_QUIET_TOOLS !== "0") gentleAiBash(pi);
+}
+
+function loadQuietTools(pi: ReturnType<typeof createPi>["pi"]): void {
+ quietTools(pi);
+}
+
function registeredQuietTools() {
const { pi, tools } = createPi();
- withEnv({ GENTLE_PI_QUIET_TOOLS: undefined }, () => quietTools(pi as any));
+ withEnv({ GENTLE_PI_QUIET_TOOLS: undefined }, () => loadQuietTools(pi));
return tools;
}
function registeredQuietToolsWithResolver(resolveOverride: () => unknown) {
const { pi, tools } = createPi();
- withEnv({ GENTLE_PI_QUIET_TOOLS: undefined }, () => quietTools(pi as any, resolveOverride as any));
+ withEnv({ GENTLE_PI_QUIET_TOOLS: undefined }, () => {
+ quietToolsExtension(pi as any, resolveOverride as any);
+ registerGentleAiBashRenderer(pi as any, resolveOverride as any);
+ });
return tools;
}
@@ -162,6 +176,28 @@ function assertGenericBash(tool: any, command: string): void {
assert.match(output, /original command output/);
}
+test("Gentle AI Bash rendering loads independently while smart-edit remains the sole edit owner", () => {
+ const smartEdit = createSdkTool("edit");
+ const { pi, tools } = createPi({ throwOnToolConflict: true });
+ pi.registerTool(smartEdit);
+
+ withEnv({ GENTLE_PI_QUIET_TOOLS: "0" }, () => {
+ quietToolsExtension(pi as any);
+ gentleAiBash(pi as any);
+ });
+
+ assert.strictEqual(tools.get("edit"), smartEdit);
+ assert.ok(tools.has("bash"), "independent extension must own the Gentle AI Bash renderer");
+ assert.equal(tools.size, 2, "quiet-tools exclusion must not register another edit owner");
+ const bash = tools.get("bash");
+ const command = "gentle-ai review status --lineage private";
+ assert.match(renderToString(bash.renderCall({ command }, passthroughTheme, { args: { command } })), /🌹︎ Gentle AI · running · review status/);
+
+ const defaultLoad = createPi({ throwOnToolConflict: true });
+ withEnv({ GENTLE_PI_QUIET_TOOLS: undefined }, () => quietTools(defaultLoad.pi as any));
+ assert.deepEqual([...defaultLoad.tools.keys()].sort(), ["bash", "edit", "find", "grep", "ls", "read", "write"]);
+});
+
test("quiet tool rendering registers noisy built-in tools", () => {
withEnv({ GENTLE_PI_QUIET_TOOLS: undefined }, () => {
const { pi, tools } = createPi();
diff --git a/tests/runtime-harness.mjs b/tests/runtime-harness.mjs
index 8cf1ae546..5f3a3f83e 100644
--- a/tests/runtime-harness.mjs
+++ b/tests/runtime-harness.mjs
@@ -15,6 +15,7 @@ import { domainHashV1 } from "../lib/review-canonical.ts";
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const EXTENSIONS = [
"extensions/gentle-ai.ts",
+ "extensions/gentle-ai-bash.ts",
"extensions/quiet-tools.ts",
"extensions/skill-registry.ts",
"extensions/sdd-init.ts",
PR authorization request
Please review this bounded patch and apply status:approved if the behavior and scope are accepted. After maintainer approval, the contributor will push the prepared branch and open a PR with Closes #<this-issue> and exactly one type:bug label.
Problem
The rose lifecycle renderer currently shares
extensions/quiet-tools.tswith the non-Bash quiet-tool overrides. When a Pi package filters that extension to preserve another tool owner, it also loses the Gentle AI Bash renderer even though command execution remains available.The result is a generic Bash row instead of the established lifecycle presentation:
Existing context and duplicate search
gentle-ai-bashextension.This issue is a narrow follow-up: preserve the already-established renderer when
extensions/quiet-tools.tsis intentionally filtered.Reproduction
extensions/quiet-tools.tsto avoid replacing another registered tool owner.gentle-ai --versionorgentle-ai review status ....Expected behavior
Filtering the non-Bash quiet tools should not disable Gentle AI Bash lifecycle rendering. Bash should have exactly one renderer owner, and the filtered extension must not re-register edit or other quiet tools.
Proposed fix
extensions/gentle-ai-bash.tsextension.extensions/quiet-tools.ts.Acceptance criteria
extensions/quiet-tools.tsis filtered.bash,edit,find,grep,ls,read, andwrite.Validation evidence
Prepared locally as commit
c8e21ec55(fix(quiet-tools): load rose renderer independently).node --experimental-strip-types tests/runtime-harness.mjs.git diff --check origin/main: passed./tmpwith/private/tmp. That unrelated baseline hunk is deliberately excluded from this patch.Proposed code diff
PR authorization request
Please review this bounded patch and apply
status:approvedif the behavior and scope are accepted. After maintainer approval, the contributor will push the prepared branch and open a PR withCloses #<this-issue>and exactly onetype:buglabel.