Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

All notable changes to `@inceptionstack/roundhouse` are documented here.

## [0.5.34] — 2026-05-14

### Added
- **`/toggle-enforce-branches`** — immediate runtime kill-switch for `pi-branch-enforcer`
- `/toggle-enforce-branches` — toggle (on ↔ off)
- `/toggle-enforce-branches on|off|status` — explicit / query
- Persists across agent restarts (writes `~/.pi-branch-enforcer/disabled` marker file)
- **Effect is immediate** — next bash tool call sees the new state, no `/restart` needed
- Requires `@inceptionstack/pi-branch-enforcer` >= 3.3.0 (which honors the marker)
- 10 unit tests covering toggle/explicit/status/aliases/idempotence/unknown-arg paths

## [0.5.32] — 2026-05-14

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@inceptionstack/roundhouse",
"version": "0.5.32",
"version": "0.5.34",
"type": "module",
"description": "Multi-platform chat gateway that routes messages through a configured AI agent",
"license": "MIT",
Expand Down
8 changes: 8 additions & 0 deletions src/gateway/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { handleNew, handleRestart, handleUpdate, handleCompact, handleStatus, ha
import { handleModel, handleModelAction, MODEL_ACTION_ID } from "./model-command";
import { handleLater } from "./later-command";
import { handleTopic, handleTopicAction, TOPIC_ACTION_ID, applyTopicOverride } from "./topic-command";
import { handleToggleEnforceBranches } from "./toggle-enforce-branches-command";
import {
type CommandDescriptor,
type CommandInvocation,
Expand Down Expand Up @@ -787,6 +788,13 @@ export class Gateway {
acceptsArgs: true,
invoke: ({ thread, text }) => handleLater({ thread, text, postWithFallback: post }),
},
{
triggers: ["/toggle-enforce-branches"],
acceptsArgs: true,
invoke: ({ thread, text }) => handleToggleEnforceBranches({
thread, text, postWithFallback: post,
}),
},
{
triggers: ["/topic"],
acceptsArgs: true,
Expand Down
107 changes: 107 additions & 0 deletions src/gateway/toggle-enforce-branches-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* gateway/toggle-enforce-branches-command.ts — /toggle-enforce-branches command
*
* Toggles the runtime kill-switch for `@inceptionstack/pi-branch-enforcer`.
*
* The extension (v3.3.0+) checks for `~/.pi-branch-enforcer/disabled` on every
* `tool_call`. Creating the file disables enforcement; removing it re-enables.
* Effect is **immediate** — no agent restart needed; the next bash command
* sees the new state.
*
* Usage:
* /toggle-enforce-branches → toggle (off→on or on→off)
* /toggle-enforce-branches on → force enable
* /toggle-enforce-branches off → force disable
* /toggle-enforce-branches status → just report current state
*
* The command intentionally lives in roundhouse rather than the extension so
* that the user-facing surface (Telegram) stays in one place. The extension
* just exposes the marker file as a stable contract.
*/

import { homedir } from "node:os";
import { join } from "node:path";
import { existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs";

/** Stable contract with pi-branch-enforcer >=3.3.0. */
const MARKER_DIR = join(homedir(), ".pi-branch-enforcer");
const MARKER_PATH = join(MARKER_DIR, "disabled");

export interface ToggleEnforceBranchesContext {
thread: any;
text: string;
postWithFallback: (thread: any, text: string) => Promise<void>;
}

/** True if enforcement is currently disabled (marker file present). */
function isDisabled(): boolean {
try { return existsSync(MARKER_PATH); } catch { return false; }
}

/** Create the marker file (disable enforcement). Idempotent. */
function disable(): void {
mkdirSync(MARKER_DIR, { recursive: true });
writeFileSync(MARKER_PATH, `disabled at ${new Date().toISOString()}\n`);
}

/** Remove the marker file (re-enable enforcement). Idempotent. */
function enable(): void {
try { unlinkSync(MARKER_PATH); } catch (err: any) {
if (err?.code !== "ENOENT") throw err;
}
}

function statusLine(disabled: boolean): string {
return disabled
? "🔓 *Branch enforcer:* DISABLED — pushes to `main`/`master` are allowed"
: "🔒 *Branch enforcer:* ENABLED — pushes to `main`/`master` are blocked";
}

export async function handleToggleEnforceBranches(ctx: ToggleEnforceBranchesContext): Promise<void> {
const { thread, text, postWithFallback } = ctx;
const arg = text.split(/\s+/)[1]?.toLowerCase() ?? "";

// Pure status query — no state change.
if (arg === "status") {
await postWithFallback(thread, statusLine(isDisabled()));
return;
}

// Resolve target state.
const currentlyDisabled = isDisabled();
let targetDisabled: boolean;
if (arg === "on" || arg === "enable") {
targetDisabled = false;
} else if (arg === "off" || arg === "disable") {
targetDisabled = true;
} else if (arg === "" || arg === "toggle") {
targetDisabled = !currentlyDisabled;
} else {
await postWithFallback(
thread,
"Usage: `/toggle-enforce-branches [on|off|status]`\n\n" +
"_(no arg toggles the current state)_",
);
return;
}

// Apply.
try {
if (targetDisabled) disable(); else enable();
} catch (err) {
await postWithFallback(thread, `⚠️ Failed to toggle: ${(err as Error).message}`);
return;
}

const noChange = targetDisabled === currentlyDisabled;
const prefix = noChange ? "ℹ️ Already in target state.\n\n" : "✅ Updated.\n\n";
const detail = targetDisabled
? "\n\n_Effect:_ next `bash` tool call will skip enforcement. Re-enable with `/toggle-enforce-branches on`."
: "\n\n_Effect:_ next `bash` tool call will resume enforcement.";
await postWithFallback(thread, prefix + statusLine(targetDisabled) + detail);

console.log(
`[roundhouse] /toggle-enforce-branches: ${currentlyDisabled ? "disabled" : "enabled"} → ` +
`${targetDisabled ? "disabled" : "enabled"}`,
);
}
1 change: 1 addition & 0 deletions src/transports/telegram/bot-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ export const BOT_COMMANDS: BotCommand[] = [
{ command: "doctor", description: "Run diagnostics" },
{ command: "crons", description: "List scheduled cron jobs" },
{ command: "jobs", description: "Show running jobs" },
{ command: "toggle-enforce-branches", description: "Toggle branch-protection enforcement" },
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Rename command to Telegram-safe identifier

The new toggle-enforce-branches entry uses hyphens, but Telegram BotCommand.command only allows lowercase letters, digits, and underscores; when registerCommands() calls setMyCommands, Telegram returns BOT_COMMAND_INVALID for this payload, so startup command registration fails and the bot command menu is not updated. This impacts every Telegram deployment that enables command registration, not just this new command, because the API call validates the full command list.

Useful? React with 👍 / 👎.

];
123 changes: 123 additions & 0 deletions test/toggle-enforce-branches.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* Tests for /toggle-enforce-branches command
*
* Strategy: characterization tests against the real filesystem using a
* dedicated tmp HOME so we don't touch ~/.pi-branch-enforcer/disabled on
* the dev machine. The handler reads HOME via os.homedir() — which on
* Linux respects $HOME — so overriding $HOME redirects all marker writes.
*
* We import the command module fresh per test (vitest module reset) so
* the homedir() value captured at module-load time picks up the new $HOME.
*/
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir, homedir } from "node:os";
import { join } from "node:path";

let tmpHome: string;
let originalHome: string | undefined;
let posts: string[];
let postWithFallback: (thread: any, text: string) => Promise<void>;

beforeEach(() => {
tmpHome = mkdtempSync(join(tmpdir(), "roundhouse-toggle-test-"));
originalHome = process.env.HOME;
process.env.HOME = tmpHome;
posts = [];
postWithFallback = async (_t, text) => { posts.push(text); };
// Force re-resolution of homedir() inside the command module
vi.resetModules();
// Sanity: confirm os.homedir() now returns our tmpHome
expect(homedir()).toBe(tmpHome);
});

afterEach(() => {
if (originalHome !== undefined) process.env.HOME = originalHome;
else delete process.env.HOME;
rmSync(tmpHome, { recursive: true, force: true });
});

const markerPath = () => join(tmpHome, ".pi-branch-enforcer", "disabled");

async function callHandler(text: string): Promise<void> {
const mod = await import("../src/gateway/toggle-enforce-branches-command");
await mod.handleToggleEnforceBranches({
thread: { id: "test" },
text,
postWithFallback,
});
}

describe("/toggle-enforce-branches", () => {
test("status_initialState_reportsEnabled", async () => {
await callHandler("/toggle-enforce-branches status");
expect(posts).toHaveLength(1);
expect(posts[0]).toContain("ENABLED");
expect(existsSync(markerPath())).toBe(false);
});

test("status_markerExists_reportsDisabled", async () => {
mkdirSync(join(tmpHome, ".pi-branch-enforcer"), { recursive: true });
writeFileSync(markerPath(), "test");
await callHandler("/toggle-enforce-branches status");
expect(posts[0]).toContain("DISABLED");
});

test("toggle_fromEnabled_disablesAndCreatesMarker", async () => {
await callHandler("/toggle-enforce-branches");
expect(existsSync(markerPath())).toBe(true);
expect(posts[0]).toContain("DISABLED");
expect(posts[0]).toContain("Updated");
});

test("toggle_fromDisabled_enablesAndRemovesMarker", async () => {
mkdirSync(join(tmpHome, ".pi-branch-enforcer"), { recursive: true });
writeFileSync(markerPath(), "x");
await callHandler("/toggle-enforce-branches");
expect(existsSync(markerPath())).toBe(false);
expect(posts[0]).toContain("ENABLED");
});

test("explicitOff_whenAlreadyEnabled_disables", async () => {
await callHandler("/toggle-enforce-branches off");
expect(existsSync(markerPath())).toBe(true);
expect(posts[0]).toContain("DISABLED");
});

test("explicitOff_whenAlreadyDisabled_isIdempotentNoChange", async () => {
mkdirSync(join(tmpHome, ".pi-branch-enforcer"), { recursive: true });
writeFileSync(markerPath(), "x");
await callHandler("/toggle-enforce-branches off");
expect(existsSync(markerPath())).toBe(true);
expect(posts[0]).toContain("Already in target state");
expect(posts[0]).toContain("DISABLED");
});

test("explicitOn_whenDisabled_enables", async () => {
mkdirSync(join(tmpHome, ".pi-branch-enforcer"), { recursive: true });
writeFileSync(markerPath(), "x");
await callHandler("/toggle-enforce-branches on");
expect(existsSync(markerPath())).toBe(false);
expect(posts[0]).toContain("ENABLED");
});

test("explicitOn_whenAlreadyEnabled_isIdempotentNoChange", async () => {
await callHandler("/toggle-enforce-branches on");
expect(existsSync(markerPath())).toBe(false);
expect(posts[0]).toContain("Already in target state");
});

test("aliases_enableAndDisable_work", async () => {
await callHandler("/toggle-enforce-branches disable");
expect(existsSync(markerPath())).toBe(true);
posts.length = 0;
await callHandler("/toggle-enforce-branches enable");
expect(existsSync(markerPath())).toBe(false);
});

test("unknownArg_postsUsageWithoutChanging", async () => {
await callHandler("/toggle-enforce-branches blah");
expect(existsSync(markerPath())).toBe(false);
expect(posts[0]).toContain("Usage:");
});
});
Loading