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
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,23 @@

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

## [0.5.40] — 2026-05-16
## [0.5.40] — 2026-05-19

### Added
- **Optional quality extensions.** `pi-hard-no` (code quality inspector) and
`pi-branch-enforcer` are now **opt-in for new setups**. Existing setups that
already have them enabled are unaffected.
- **Toggle commands:** `/toggle-quality-inspector [on|off]` and
`/toggle-branch-enforcer [on|off]` let users enable/disable these extensions
from chat. No argument shows current state with inline keyboard buttons.
Idempotent setter semantics (safe for delivery dups).
- **Shared `pi-settings` module** (`src/pi-settings.ts`): centralises all
`~/.pi/agent/settings.json` read/write/update logic with atomic writes,
per-process queue + on-disk lockfile (`proper-lockfile`), and
`MalformedPiSettingsError` on parse failure. All 4 existing writers migrated.
- **Doctor check improvement:** `pi-settings` check now distinguishes
ENOENT (warn, “not found”) from SyntaxError (fail, “malformed JSON” +
parse error message).

### Changed
- **Refactor:** Renamed `/stop` command to `/cancel` for semantic clarity.
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,8 @@ Roundhouse automatically registers these commands with Telegram on startup:
| `/doctor` | Run health checks and show system status |
| `/crons` | Manage scheduled jobs (list, trigger, pause, resume) |
| `/jobs` | List scheduled jobs (alias for /crons) |
| `/toggle-quality-inspector` | Toggle pi-hard-no code review on/off |
| `/toggle-branch-enforcer` | Toggle pi-branch-enforcer guard on/off |

These appear in Telegram's `/` command menu automatically.

Expand Down Expand Up @@ -254,6 +256,24 @@ When extensions (e.g. code review) queue follow-up work after the agent responds

Fast operations that complete within 2 seconds show no extra messages.

### Optional extensions

Roundhouse ships with two optional pi extensions that are **not enabled by default** for new setups:

- **pi-hard-no** — Code quality inspector that reviews agent output
- **pi-branch-enforcer** — Guards against committing to protected branches

These are installed globally by `/update` but must be explicitly enabled:

| Command | Description |
|---------|-------------|
| `/toggle-quality-inspector [on\|off]` | Enable/disable pi-hard-no. No arg shows current state + buttons. |
| `/toggle-branch-enforcer [on\|off]` | Enable/disable pi-branch-enforcer. No arg shows current state + buttons. |

After toggling, run `/restart` for the change to take effect (pi loads extensions at startup).

**Existing setups** that already have these extensions enabled are unaffected — toggling off is opt-in.

## File attachments

Roundhouse handles voice messages, images, documents, and other file attachments from Telegram:
Expand Down
5 changes: 3 additions & 2 deletions package-lock.json

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

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@inceptionstack/roundhouse",
"version": "0.5.38",
"version": "0.5.40",
"type": "module",
"description": "Multi-platform chat gateway that routes messages through a configured AI agent",
"license": "MIT",
Expand Down Expand Up @@ -45,6 +45,7 @@
"chat": "^4.26.0",
"croner": "^10.0.1",
"p-queue": "^9.2.0",
"proper-lockfile": "^4.1.2",
"qrcode-terminal": "^0.12.0",
"tsx": "^4.0.0"
},
Expand Down
41 changes: 29 additions & 12 deletions src/cli/doctor/checks/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,26 +53,43 @@ export const agentChecks: DoctorCheck[] = [
id: "pi-settings", category: "agent", name: "Pi settings",
async run() {
const settingsPath = join(homedir(), ".pi", "agent", "settings.json");
let raw: string;
try {
const raw = await readFile(settingsPath, "utf8");
const settings = JSON.parse(raw);
const model = settings.defaultModel ? `${settings.defaultProvider}/${settings.defaultModel}` : "not configured";
const issues: string[] = [];
if (!settings.defaultProvider) issues.push("No defaultProvider set");
if (!settings.defaultModel) issues.push("No defaultModel set");
raw = await readFile(settingsPath, "utf8");
} catch (err: any) {
if (err.code === "ENOENT") {
return {
id: "pi-settings", category: "agent", name: "Pi settings",
status: "warn" as const, summary: "not found",
details: [`${settingsPath} does not exist`],
};
}
return {
id: "pi-settings", category: "agent", name: "Pi settings",
status: issues.length ? "warn" : "pass",
summary: issues.length ? `${issues.length} issue(s)` : `model: ${model}`,
details: issues.length ? issues : undefined,
status: "fail" as const, summary: `read error: ${err.message}`,
details: [`Failed to read ${settingsPath}: ${err.message}`],
};
} catch {
}
let settings: any;
try {
settings = JSON.parse(raw);
} catch (parseErr: any) {
return {
id: "pi-settings", category: "agent", name: "Pi settings",
status: "warn", summary: "not found",
details: [`${settingsPath} does not exist`],
status: "fail" as const, summary: "malformed JSON",
details: [`${settingsPath}: ${parseErr.message}`],
};
}
const model = settings.defaultModel ? `${settings.defaultProvider}/${settings.defaultModel}` : "not configured";
const issues: string[] = [];
if (!settings.defaultProvider) issues.push("No defaultProvider set");
if (!settings.defaultModel) issues.push("No defaultModel set");
return {
id: "pi-settings", category: "agent", name: "Pi settings",
status: issues.length ? "warn" : "pass",
summary: issues.length ? `${issues.length} issue(s)` : `model: ${model}`,
details: issues.length ? issues : undefined,
};
},
},
];
74 changes: 38 additions & 36 deletions src/cli/setup/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { dirname } from "node:path";
import { readFile, mkdir } from "node:fs/promises";
import { atomicWriteJson, execOrFail } from "./helpers";
import { readFile } from "node:fs/promises";
import { execOrFail } from "./helpers";
import { type SetupOptions, type StepLog, PI_SETTINGS_PATH } from "./types";
import { updatePiSettings } from "../../pi-settings";
import {
getAgentDefinition,
type AgentDefinition,
Expand All @@ -19,47 +19,49 @@ export function resolveAgentForSetup(opts: SetupOptions, logger: StepLog): Agent
existing = JSON.parse(await readFile(PI_SETTINGS_PATH, "utf8"));
} catch {}

const settings: Record<string, unknown> = { ...existing };
await updatePiSettings((settings) => {
// Merge with what we read above (preserves any concurrent writes
// since our read — the lock serialises the final RMW).
const merged = { ...settings };

if (ctx.force) {
settings.defaultProvider = ctx.provider;
settings.defaultModel = ctx.model;
} else {
if (existing.defaultProvider && existing.defaultProvider !== ctx.provider) {
logger.warn(`Pi provider already set to '${existing.defaultProvider}' (keeping, use --force to override)`);
if (ctx.force) {
merged.defaultProvider = ctx.provider;
merged.defaultModel = ctx.model;
} else {
settings.defaultProvider = ctx.provider;
if (existing.defaultProvider && existing.defaultProvider !== ctx.provider) {
logger.warn(`Pi provider already set to '${existing.defaultProvider}' (keeping, use --force to override)`);
} else {
merged.defaultProvider = ctx.provider;
}
if (existing.defaultModel && existing.defaultModel !== ctx.model) {
logger.warn(`Pi model already set to '${existing.defaultModel}' (keeping, use --force to override)`);
} else {
merged.defaultModel = ctx.model;
}
}
if (existing.defaultModel && existing.defaultModel !== ctx.model) {
logger.warn(`Pi model already set to '${existing.defaultModel}' (keeping, use --force to override)`);
} else {
settings.defaultModel = ctx.model;
}
}

if (!Array.isArray(settings.packages)) settings.packages = [];
if (!Array.isArray(merged.packages)) merged.packages = [];

const pkgs = settings.packages as string[];
const selfPkg = "npm:@inceptionstack/roundhouse";
const selfIdx = pkgs.indexOf(selfPkg);
if (selfIdx !== -1) pkgs.splice(selfIdx, 1);
const pkgs = merged.packages as string[];
const selfPkg = "npm:@inceptionstack/roundhouse";
const selfIdx = pkgs.indexOf(selfPkg);
if (selfIdx !== -1) pkgs.splice(selfIdx, 1);

const coreExtensions = [
"npm:@inceptionstack/pi-hard-no",
"npm:@inceptionstack/pi-branch-enforcer",
];
for (const ext of coreExtensions) {
if (!pkgs.includes(ext)) pkgs.push(ext);
}
// coreExtensions is empty — pi-hard-no and pi-branch-enforcer are now opt-in
const coreExtensions: string[] = [];
for (const ext of coreExtensions) {
if (!pkgs.includes(ext)) pkgs.push(ext);
}

if (ctx.psst) {
const psstPkg = "npm:@miclivs/pi-psst";
if (!pkgs.includes(psstPkg)) pkgs.push(psstPkg);
}

if (ctx.psst) {
const psstPkg = "npm:@miclivs/pi-psst";
if (!pkgs.includes(psstPkg)) pkgs.push(psstPkg);
}
return merged;
});

await mkdir(dirname(PI_SETTINGS_PATH), { recursive: true });
await atomicWriteJson(PI_SETTINGS_PATH, settings);
logger.ok(`~/.pi/agent/settings.json (${settings.defaultProvider}, ${settings.defaultModel})`);
logger.ok(`~/.pi/agent/settings.json (${existing.defaultProvider ?? ctx.provider}, ${existing.defaultModel ?? ctx.model})`);
};

agent.installExtension = async (ext: string) => {
Expand Down
24 changes: 13 additions & 11 deletions src/cli/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@

import { homedir } from "node:os";
import { execSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { provisionBundle } from "../provisioning/bundle";
import { updatePiSettings } from "../pi-settings";

const GLOBAL_PI_EXTENSION_PACKAGES = [
"@inceptionstack/pi-hard-no",
Expand Down Expand Up @@ -117,16 +117,18 @@ export async function updateSelf(
}
}

export function patchPiSettings(): void {
export async function patchPiSettings(): Promise<void> {
try {
const settingsPath = `${homedir()}/.pi/agent/settings.json`;
const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
const selfPkg = "npm:@inceptionstack/roundhouse";
if (!Array.isArray(settings.packages)) settings.packages = [];
if (!settings.packages.includes(selfPkg)) {
settings.packages.push(selfPkg);
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
}
await updatePiSettings((settings) => {
const selfPkg = "npm:@inceptionstack/roundhouse";
if (!Array.isArray(settings.packages)) settings = { ...settings, packages: [] };
const pkgs = [...(settings.packages as string[])];
if (!pkgs.includes(selfPkg)) {
pkgs.push(selfPkg);
return { ...settings, packages: pkgs };
}
return settings;
});
} catch { /* settings.json may not exist yet — fine, setup will create it */ }
}

Expand Down Expand Up @@ -173,7 +175,7 @@ export async function performUpdate(progress: UpdateProgress): Promise<UpdateRes
console.warn("[roundhouse] bundle provisioning failed:", e instanceof Error ? e.message : e);
}

patchPiSettings();
await patchPiSettings();

return { action: "updated", currentVersion, latestVersion };
}
Loading
Loading