Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.
Merged
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
12 changes: 5 additions & 7 deletions .claude/hooks/protect-files.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,12 @@ if [ -z "$FILE_PATH" ]; then
exit 0
fi

# Normalize path: prevent bypass via non-canonical paths
# Collapse multiple slashes: // → /
while [[ "$FILE_PATH" == *//* ]]; do
FILE_PATH="${FILE_PATH//\/\//\/}"
done
# Collapse /./ → /
# Normalize path: prevent bypass via non-canonical paths.
# Use sed rather than Bash replacement syntax so the replacement stays literal
# and cannot accidentally introduce backslashes while collapsing `//` or `/./`.
FILE_PATH=$(printf '%s' "$FILE_PATH" | sed -E 's#/+#/#g')
while [[ "$FILE_PATH" == *"/./"* ]]; do
FILE_PATH="${FILE_PATH//\/.\//\/}"
FILE_PATH=$(printf '%s' "$FILE_PATH" | sed 's#/\./#/#g')
done
# Resolve /component/.. sequences
while [[ "$FILE_PATH" == *"/.."* ]]; do
Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ Five hooks are wired in `.claude/settings.json`:
- Bundled scout/planner/reviewer agents allow `web_search` and `web_fetch`; worker has no explicit tools allowlist.
- Use `thinking` for Pi agents; `effort` is obsolete and rejected by config validation.
- Runtime bot tokens use `bot/src/secrets.ts`: SOPS first, then configured env; legacy `*tokenService` Keychain fields are rejected. Telegram/Discord SOPS files resolve relative to the bot config file, while Tavily uses `config/secrets.sops.yaml` relative to each Pi session `workspaceCwd` and should contain only `tavily.api_key`.
- Workspace contract defaults live in `bot/src/workspace-contract.ts`: CLI `--workspace`, then `MINIME_WORKSPACE_ROOT`, then source-checkout fallback. `MINIME_CONFIG_PATH`, `MINIME_CRONS_PATH`, and `MINIME_SCHEMA_PATH` resolve relative to the workspace root; relative agent `workspaceCwd` values are resolved against that root and agent workspaces must stay inside it before runtime spawns.
- Package extension artifacts are generated under `bot/dist/extensions/pi` by `npm run build` / `npm pack`; source development still uses `bot/.claude/extensions`.
- Bot validation commands: `cd bot && npm test`, `npm run typecheck`, and `npm run validate-config`.
- Package validation commands: `cd bot && npm run build`, `npm run workspace:validate -- --workspace ./test-fixtures/minimal-workspace`, and `npm pack --dry-run`.
- Sampler dry-run check: `cd bot && CODEX_QUOTA_TEXTFILE_DIR=/tmp/codex-quota-test CODEX_QUOTA_STATE_FILE=/tmp/codex-quota-test/state.json npx tsx scripts/codex-quota-sampler.ts --dry-run`.

## Skills
Expand Down
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Both platforms share one Session Manager and use the same stream-relay logic via
### Prerequisites

- macOS (launchd required for bot service management)
- Node.js 20+ and npm
- Node.js 22.19+ and npm (Pi package dependencies require Node >=22.19.0)
- `jq` — required by hook scripts (`brew install jq`)
- `sops` and `age`, with an age identity available to the launchd user unless you configure only explicit token environment variables
- The `pi` binary on launchd `PATH` and Pi auth initialized for the launchd user with `pi /login`
Expand Down Expand Up @@ -373,6 +373,30 @@ The bot maintains persistent context across sessions through a memory system roo

## Configuration

### CLI validation

The package exposes a built CLI as `minime-bot` after `npm run build`, `npm pack`, or package installation. Package-installed Pi dependencies require Node >=22.19.0, matching `bot/package.json` `engines.node`. From a source checkout, the same workspace validator is available through npm scripts:

```bash
cd bot
npm run build
npm run workspace:validate -- --workspace ./test-fixtures/minimal-workspace
npm run validate-config
node dist/cli.js --help
```

Installed-package commands use the same surface:

```bash
minime-bot --help
minime-bot config validate --workspace /path/to/workspace
minime-bot workspace validate --workspace /path/to/workspace
```

`--workspace` takes precedence over `MINIME_WORKSPACE_ROOT`; if neither is set in the current source checkout, the workspace defaults to the repository root. To validate the repository root itself, make sure it has the expected workspace files, including `schema.md` and configured agent `workspaceCwd` directories. `MINIME_CONFIG_PATH`, `MINIME_CRONS_PATH`, and `MINIME_SCHEMA_PATH` override the corresponding workspace files and resolve relative to the workspace root when not absolute.

Validation is structural by default. These commands load config with secret resolution disabled, parse crons and the workspace `schema.md` write allow-list, and print effective paths without decrypting SOPS files or printing secret values. Hard failures include an absent or invalid workspace root, missing or invalid config, malformed crons, missing/empty/malformed schema while guards are enabled, missing configured agent workspaces, agent workspaces outside the resolved workspace root, a missing Pi extension directory, or validator/live-guard schema path disagreement. A missing crons file is a warning. Setting `PI_EXTENSIONS_DISABLED=1` skips schema enforcement as a warning for workspace validation, but Pi LLM crons still require the A1 guard.

### Provider backends

Interactive agents run through Pi RPC + OpenAI Codex. The optional per-agent `provider` field remains only as a compatibility field:
Expand Down Expand Up @@ -402,7 +426,7 @@ The Pi binary (`@earendil-works/pi-coding-agent`) is resolved from `PATH`; the b

#### Pi extensions (A1-A3)

Every `pi --mode rpc` spawn suppresses Pi's ambient extension discovery with `--no-extensions`, then loads three first-party extensions so Pi sessions reach parity with the workspace guard, web-tools, and subagent capabilities expected by deployed agents. They are loaded as repeatable `--extension <abs-path>` args appended by `buildPiSpawnArgs` (see [resolvePiExtensionArgs](bot/src/pi-rpc-protocol.ts)) — loading is deliberately per-spawn rather than via Pi's auto-discovery dirs.
Every `pi --mode rpc` spawn suppresses Pi's ambient extension discovery with `--no-extensions`, then loads three first-party extensions so Pi sessions reach parity with the workspace guard, web-tools, and subagent capabilities expected by deployed agents. They are loaded as repeatable `--extension <abs-path>` args appended by `buildPiSpawnArgs` (see [resolvePiExtensionArgs](bot/src/pi-rpc-protocol.ts)) — loading is deliberately per-spawn rather than via Pi's auto-discovery dirs. Source checkout runs load the TypeScript wrappers under `bot/.claude/extensions/`; built and installed package runs load generated wrappers under `bot/dist/extensions/pi/` or `node_modules/minime/dist/extensions/pi/`, including copied subagent `agents/*.md` and `prompts/*.md` resources.

| Extension | Wrapper | What it does |
|---|---|---|
Expand Down
65 changes: 16 additions & 49 deletions bot/.claude/extensions/guardian-protect-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@
* `docs/plans/2026-06-02-pi-claude-write-guard-enforcers.md`.
*/

import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { classifyToolCall } from "../../src/pi-extensions/guard.js";
import {
readWriteAllowlistEntriesForGuard,
resolveWriteAllowlistSchemaPath,
} from "../../src/pi-extensions/write-allowlist-schema.js";
import { PI_GUARD_WORKSPACE_ROOT_ENV } from "../../src/pi-rpc-protocol.js";

/**
Expand All @@ -34,27 +36,17 @@ import { PI_GUARD_WORKSPACE_ROOT_ENV } from "../../src/pi-rpc-protocol.js";
*/
const WRITE_TARGET_TOOLS = new Set(["write", "edit", "bash"]);

/** The fence tag that opens the single write-allowlist block in `schema.md`. */
const WRITE_ALLOWLIST_FENCE = "```write-allowlist";

/**
* Per-process cache of the parsed write-allowlist, keyed by workspace root. The
* Per-process cache of the parsed write-allowlist, keyed by schema path. The
* `schema.md` block is read once per spawn (Pi sessions are short-lived); an
* edit to `schema.md` is picked up on the next spawn, not mid-session.
*/
const writeAllowlistCache = new Map<string, string[]>();

/**
* Read the workspace write allow-list — the lines of the single
* ```` ```write-allowlist ```` fenced block in `<workspaceRoot>/schema.md`.
* Mirrors the awk extraction `guardian.sh` uses
* (`/^```write-allowlist$/{f=1;next} f&&/^```/{exit} f`): the lines strictly
* between an opening fence that is EXACTLY ```` ```write-allowlist ```` and the
* next line starting with ```` ``` ````. Both stop after the FIRST block (the awk
* `exit`s, this loop `break`s) so they stay identical even if schema.md carries a
* second block against its contract. Each extracted line then has `#` comments
* stripped, is trimmed, and blanks dropped — the same stripping the guardian
* orphan-allowlist uses.
* Read the workspace write allow-list from the resolved schema path. When
* MINIME_SCHEMA_PATH is set, it is resolved exactly like the workspace contract:
* absolute paths are used as-is and relative paths are based on the guard root.
*
* Returns the parsed lines, or an EMPTY array when `schema.md` is missing /
* unreadable or has no `write-allowlist` block. The empty array is DELIBERATE
Expand All @@ -67,47 +59,22 @@ const writeAllowlistCache = new Map<string, string[]>();
* filesystem; this wrapper does the I/O and injects the result.
*/
function readWriteAllowlist(workspaceRoot: string): string[] {
const cached = writeAllowlistCache.get(workspaceRoot);
const schemaPath = resolveWriteAllowlistSchemaPath(workspaceRoot, process.env);
const cached = writeAllowlistCache.get(schemaPath);
if (cached !== undefined) {
return cached;
}
const lines: string[] = [];
let content: string;
try {
content = readFileSync(join(workspaceRoot, "schema.md"), "utf8");
} catch {
// Missing/unreadable schema.md → empty list → fail-closed in the classifier.
writeAllowlistCache.set(workspaceRoot, lines);
return lines;
}
let inBlock = false;
for (const rawLine of content.split("\n")) {
if (!inBlock) {
if (rawLine === WRITE_ALLOWLIST_FENCE) {
inBlock = true;
}
continue;
}
if (rawLine.startsWith("```")) {
break; // closing fence of the write-allowlist block
}
const line = rawLine.replace(/#.*$/, "").trim();
if (line) {
lines.push(line);
}
}
writeAllowlistCache.set(workspaceRoot, lines);
const lines = readWriteAllowlistEntriesForGuard(schemaPath);
writeAllowlistCache.set(schemaPath, lines);
return lines;
}

export default function (pi: ExtensionAPI): void {
pi.on("tool_call", async (event, ctx) => {
// Protection is anchored at the IMMUTABLE workspace root. For a subagent
// CHILD that is the parent workspace (carried in PI_GUARD_WORKSPACE_ROOT), so
// a caller-supplied `cwd` cannot move the guard root and let a delegated
// absolute write reach a protected dir. For a top-level parent the env is
// unset (scrubbed by buildPiSpawnEnv) → the guard root IS `ctx.cwd`. Relative
// targets still resolve against the real `ctx.cwd` (where the OS writes them).
// Protection is anchored at the IMMUTABLE workspace root carried in
// PI_GUARD_WORKSPACE_ROOT. Top-level agents and subagent children can both run
// from a child cwd, so relative targets still resolve against the real
// `ctx.cwd` (where the OS writes them).
const guardRoot = process.env[PI_GUARD_WORKSPACE_ROOT_ENV]?.trim() || ctx.cwd;

// Schema-enforced DENY-BY-DEFAULT (the new model). Read the `schema.md`
Expand Down
10 changes: 10 additions & 0 deletions bot/package-lock.json

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

28 changes: 26 additions & 2 deletions bot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,35 @@
"name": "minime",
"version": "2026.03.0",
"description": "Multi-agent Telegram and Discord bot powered by Pi/Codex sessions",
"main": "index.js",
"main": "./dist/main.js",
"bin": {
"minime-bot": "./dist/cli.js"
},
"repository": {
"type": "git",
"url": "https://github.com/Fitz123/claude-code-bot.git"
},
"scripts": {
"test": "MINIME_TEST_MEDIA_BASE=/tmp/bot-media-test node --experimental-test-module-mocks --import tsx --test src/__tests__/*.test.ts",
"build": "tsc",
"build": "node scripts/clean-package-dist.mjs && tsc && node scripts/build-package-artifacts.mjs",
"lint": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"prepare": "npm run build",
"prepack": "npm run build",
"workspace:validate": "tsx src/cli.ts workspace validate",
"validate-config": "tsx src/config.ts --validate --no-resolve-secrets"
},
"files": [
"dist/*.d.ts",
"dist/*.js",
"dist/*.js.map",
"dist/pi-extensions/**/*.d.ts",
"dist/pi-extensions/**/*.js",
"dist/pi-extensions/**/*.js.map",
"dist/extensions/pi/**/*.js",
"dist/extensions/pi/**/*.md",
"scripts/deliver.sh"
],
"keywords": [
"telegram",
"discord",
Expand All @@ -26,13 +43,20 @@
"author": "Nico Bailon",
"license": "MIT",
"type": "module",
Comment thread
fitz123 marked this conversation as resolved.
"engines": {
"node": ">=22.19.0"
},
"dependencies": {
"@earendil-works/pi-agent-core": "^0.75.3",
"@earendil-works/pi-ai": "^0.75.3",
"@earendil-works/pi-coding-agent": "0.75.3",
"@earendil-works/pi-tui": "^0.75.3",
"@grammyjs/auto-retry": "^2.0.2",
"discord.js": "^14.25.1",
"grammy": "^1.41.1",
"p-queue": "^9.1.0",
"prom-client": "^15.1.3",
"typebox": "^1.1.24",
"yaml": "^2.8.2"
},
"devDependencies": {
Expand Down
72 changes: 72 additions & 0 deletions bot/scripts/build-package-artifacts.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/env node
import { chmodSync, cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";

const scriptDir = dirname(fileURLToPath(import.meta.url));
const packageRoot = resolve(scriptDir, "..");
const sourceExtensionDir = join(packageRoot, ".claude", "extensions");
const artifactExtensionDir = join(packageRoot, "dist", "extensions", "pi");

const wrappers = [
["guardian-protect-files.ts", "guardian-protect-files.js"],
["web-tools.ts", "web-tools.js"],
[join("subagent", "agents.ts"), join("subagent", "agents.js")],
[join("subagent", "index.ts"), join("subagent", "index.js")],
];

rmSync(artifactExtensionDir, { recursive: true, force: true });

function rewriteImports(source) {
return source
.replaceAll("../../src/pi-extensions/", "../../pi-extensions/")
.replaceAll("../../src/pi-rpc-protocol.js", "../../pi-rpc-protocol.js")
.replaceAll("../../../src/pi-extensions/", "../../../pi-extensions/")
.replaceAll("../../../src/pi-rpc-protocol.js", "../../../pi-rpc-protocol.js")
.replaceAll('from "./agents.ts"', 'from "./agents.js"');
}

function transpileWrapper(sourcePath, targetPath) {
const source = rewriteImports(readFileSync(sourcePath, "utf8"));
const result = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.ES2022,
moduleResolution: ts.ModuleResolutionKind.Bundler,
esModuleInterop: true,
sourceMap: false,
},
fileName: sourcePath,
reportDiagnostics: true,
});
const diagnostics = result.diagnostics?.filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error) ?? [];
if (diagnostics.length > 0) {
throw new Error(ts.formatDiagnosticsWithColorAndContext(diagnostics, {
getCanonicalFileName: (fileName) => fileName,
getCurrentDirectory: () => packageRoot,
getNewLine: () => "\n",
}));
}
mkdirSync(dirname(targetPath), { recursive: true });
writeFileSync(targetPath, result.outputText, "utf8");
}

for (const [sourceRel, targetRel] of wrappers) {
transpileWrapper(
join(sourceExtensionDir, sourceRel),
join(artifactExtensionDir, targetRel),
);
}

for (const rel of [
join("subagent", "agents"),
join("subagent", "prompts"),
]) {
cpSync(join(sourceExtensionDir, rel), join(artifactExtensionDir, rel), {
recursive: true,
force: true,
});
}

chmodSync(join(packageRoot, "dist", "cli.js"), 0o755);
9 changes: 9 additions & 0 deletions bot/scripts/clean-package-dist.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/usr/bin/env node
import { rmSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const scriptDir = dirname(fileURLToPath(import.meta.url));
const packageRoot = resolve(scriptDir, "..");

rmSync(join(packageRoot, "dist"), { recursive: true, force: true });
Loading
Loading