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
1,853 changes: 446 additions & 1,407 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"@anthropic-ai/sdk": "^0.71.2",
"@clack/prompts": "^1.1.0",
"@kubernetes/client-node": "^1.4.0",
"@mariozechner/pi-coding-agent": "^0.55.3",
"@mariozechner/pi-coding-agent": "^0.70.6",
"@modelcontextprotocol/sdk": "^1.27.1",
"@sinclair/typebox": "^0.34.0",
"adm-zip": "^0.5.17",
Expand Down
6 changes: 3 additions & 3 deletions src/cli-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ void debugPodGC.start(credentialsDir).catch((err) => {
});

// Create session via shared factory
const { brain, session, modelFallbackMessage, customTools, skillsDirs, memoryIndexer, mcpManager } =
const { brain, session, runtime, modelFallbackMessage, customTools, skillsDirs, memoryIndexer, mcpManager } =
await createSiclawSession({
sessionManager,
mode: "cli",
Expand Down Expand Up @@ -331,12 +331,12 @@ if (debugMode) {

// Select run mode
if (isPrintMode && initialMessage) {
await runPrintMode(session, {
await runPrintMode(runtime, {
mode: "text",
initialMessage,
});
} else {
const mode = new InteractiveMode(session, { modelFallbackMessage });
const mode = new InteractiveMode(runtime, { modelFallbackMessage });

// Workaround: framework's getRegisteredToolDefinition only checks extension-registered
// tools via extensionRunner.getAllRegisteredTools(), missing SDK custom tools passed
Expand Down
91 changes: 72 additions & 19 deletions src/core/agent-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,18 @@ import {
DefaultResourceLoader,
SessionManager,
AuthStorage,
AgentSessionRuntime,
ModelRegistry,
createReadTool,
createEditTool,
createWriteTool,
createGrepTool,
createFindTool,
createLsTool,
SettingsManager,
createReadToolDefinition,
createEditToolDefinition,
createWriteToolDefinition,
createGrepToolDefinition,
createFindToolDefinition,
createLsToolDefinition,
type AgentSession,
type AgentSessionServices,
type LoadExtensionsResult,
type ToolDefinition,
type ExtensionAPI,
} from "@mariozechner/pi-coding-agent";
Expand Down Expand Up @@ -120,6 +124,8 @@ export interface CreateSiclawSessionOpts {
export interface SiclawSessionResult {
brain: BrainSession;
session: AgentSession; // backward compat — only set for pi-agent brain
runtime: AgentSessionRuntime;
extensionsResult: LoadExtensionsResult;
modelFallbackMessage?: string;
customTools: ToolDefinition[];
kubeconfigRef: KubeconfigRef;
Expand Down Expand Up @@ -147,6 +153,16 @@ function resolveEmbeddingConfig(): MemoryIndexerOpts | undefined {
return emb;
}

function expandTildePath(value: string): string {
if (value === "~") return os.homedir();
if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
return value;
}

function resolvePiAgentDir(): string {
return expandTildePath(process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent"));
}

/**
* Truncate content to a character budget using head + tail strategy.
* Subtracts the marker length from available budget before splitting.
Expand Down Expand Up @@ -253,7 +269,8 @@ export async function createSiclawSession(
): Promise<SiclawSessionResult> {
const config = loadConfig();

const authStorage = AuthStorage.create();
const agentDir = resolvePiAgentDir();
const authStorage = AuthStorage.create(path.join(agentDir, "auth.json"));

// Bridge Siclaw-configured apiKey into pi-agent's credential chain (highest priority)
const defaultLlm = getDefaultLlm();
Expand All @@ -273,7 +290,8 @@ export async function createSiclawSession(
fs.writeFileSync(configPath, JSON.stringify({ providers: config.providers }, null, 2) + "\n");
}
const modelsJson = fs.existsSync(configPath) ? configPath : undefined;
const modelRegistry = new ModelRegistry(authStorage, modelsJson);
const modelRegistry = ModelRegistry.create(authStorage, modelsJson);
const settingsManager = SettingsManager.create(process.cwd(), agentDir);

const kubeconfigRef: KubeconfigRef = opts?.kubeconfigRef ?? {};
const userId = opts?.userId ?? "unknown";
Expand Down Expand Up @@ -338,7 +356,7 @@ export async function createSiclawSession(

const allowedTools = opts?.allowedTools ?? config.allowedTools;

const customTools = registry.resolve({
const customTools: ToolDefinition[] = registry.resolve({
mode,
refs: {
kubeconfigRef, userId, agentId, sessionIdRef,
Expand Down Expand Up @@ -413,32 +431,32 @@ export async function createSiclawSession(
const writeAllowedDirs = [userDataDir];

const restrictedFileTools = [
createReadTool(cwd, {
createReadToolDefinition(cwd, {
operations: {
readFile: async (p) => { assertPathAllowed(p, readAllowedDirs, "read"); return fsReadFile(p); },
access: async (p) => { assertPathAllowed(p, readAllowedDirs, "read"); return fsAccess(p, fs.constants.R_OK); },
},
}),
createEditTool(cwd, {
createEditToolDefinition(cwd, {
operations: {
readFile: async (p) => { assertPathAllowed(p, writeAllowedDirs, "edit"); return fsReadFile(p); },
writeFile: async (p, c) => { assertPathAllowed(p, writeAllowedDirs, "edit"); return fsWriteFile(p, c, "utf-8"); },
access: async (p) => { assertPathAllowed(p, writeAllowedDirs, "edit"); return fsAccess(p, fs.constants.R_OK | fs.constants.W_OK); },
},
}),
createWriteTool(cwd, {
createWriteToolDefinition(cwd, {
operations: {
writeFile: async (p, c) => { assertPathAllowed(p, writeAllowedDirs, "write"); return fsWriteFile(p, c, "utf-8"); },
mkdir: async (d) => { assertPathAllowed(d, writeAllowedDirs, "write"); await fsMkdir(d, { recursive: true }); },
},
}),
createGrepTool(cwd, {
createGrepToolDefinition(cwd, {
operations: {
isDirectory: (p) => { assertPathAllowed(p, readAllowedDirs, "grep"); return fs.statSync(p).isDirectory(); },
readFile: (p) => { assertPathAllowed(p, readAllowedDirs, "grep"); return fs.readFileSync(p, "utf-8"); },
},
}),
createFindTool(cwd, {
createFindToolDefinition(cwd, {
operations: {
exists: (p) => { assertPathAllowed(p, readAllowedDirs, "find"); return fs.existsSync(p); },
glob: (pattern, searchCwd, options) => {
Expand All @@ -447,16 +465,17 @@ export async function createSiclawSession(
},
},
}),
createLsTool(cwd, {
createLsToolDefinition(cwd, {
operations: {
exists: (p) => { assertPathAllowed(p, readAllowedDirs, "ls"); return fs.existsSync(p); },
stat: (p) => { assertPathAllowed(p, readAllowedDirs, "ls"); return fs.statSync(p); },
readdir: (p) => { assertPathAllowed(p, readAllowedDirs, "ls"); return fs.readdirSync(p); },
},
}),
];
] as unknown as ToolDefinition[];
// Push into customTools so they override framework defaults via extension mechanism
customTools.push(...restrictedFileTools);
const activeToolNames = Array.from(new Set(customTools.map((tool) => tool.name)));

// Skills: when userId is set (local mode), use per-user directory for isolation;
// otherwise "." collapses to skillsBase/user/ (K8s single-user pod).
Expand Down Expand Up @@ -533,6 +552,8 @@ export async function createSiclawSession(

loader = new DefaultResourceLoader({
cwd,
agentDir,
settingsManager,
systemPromptOverride: () => buildSreSystemPrompt(mode, opts?.systemPromptTemplate),
appendSystemPromptOverride: () => {
const parts = buildAppendSystemPrompt(memoryDir);
Expand Down Expand Up @@ -593,13 +614,16 @@ export async function createSiclawSession(
)
: undefined;

const { session, modelFallbackMessage } = await createAgentSession({
tools: restrictedFileTools,
const { session, modelFallbackMessage, extensionsResult } = await createAgentSession({
cwd,
agentDir,
tools: activeToolNames,
customTools,
resourceLoader: loader,
sessionManager,
authStorage,
modelRegistry,
settingsManager,
model: configuredModel,
thinkingLevel: "high",
});
Expand All @@ -618,6 +642,35 @@ export async function createSiclawSession(
const guardRegistry = createGuardRegistry(contextWindow);
installGuardPipeline(guardRegistry, { agent: session.agent, sessionManager });

const services: AgentSessionServices = {
cwd,
agentDir,
authStorage,
settingsManager,
modelRegistry,
resourceLoader: loader,
diagnostics: [],
};
const runtime = new AgentSessionRuntime(
session,
services,
async (runtimeOptions) => {
const recreated = await createSiclawSession({
...opts,
sessionManager: runtimeOptions.sessionManager,
});
return {
session: recreated.session,
extensionsResult: recreated.extensionsResult,
services: recreated.runtime.services,
diagnostics: [],
modelFallbackMessage: recreated.modelFallbackMessage,
};
},
[],
modelFallbackMessage,
);

const brain: BrainSession = new PiAgentBrain(session);
return { brain, session, modelFallbackMessage, customTools, kubeconfigRef, skillsDirs, mode, mcpManager, memoryIndexer, sessionIdRef, dpStateRef };
return { brain, session, runtime, extensionsResult, modelFallbackMessage, customTools, kubeconfigRef, skillsDirs, mode, mcpManager, memoryIndexer, sessionIdRef, dpStateRef };
}
4 changes: 4 additions & 0 deletions src/core/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ async function summarizeChunks(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
headers?: Record<string, string>;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
Expand All @@ -369,6 +370,7 @@ async function summarizeChunks(params: {
params.model,
params.reserveTokens,
params.apiKey,
params.headers,
params.signal,
effectiveInstructions,
summary,
Expand Down Expand Up @@ -409,6 +411,7 @@ export async function summarizeWithFallback(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
headers?: Record<string, string>;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
Expand Down Expand Up @@ -474,6 +477,7 @@ export async function summarizeInStages(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
headers?: Record<string, string>;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
Expand Down
4 changes: 2 additions & 2 deletions src/core/extensions/compaction-safeguard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ describe("compactionSafeguardExtension", () => {
};
const ctx = {
model: undefined, // no model
modelRegistry: { getApiKey: vi.fn() },
modelRegistry: { getApiKeyAndHeaders: vi.fn() },
};

const result = await handler(event, ctx);
Expand All @@ -442,7 +442,7 @@ describe("compactionSafeguardExtension", () => {
};
const ctx = {
model: { contextWindow: 200000, provider: "test", id: "test-model" },
modelRegistry: { getApiKey: vi.fn().mockResolvedValue(undefined) }, // no key
modelRegistry: { getApiKeyAndHeaders: vi.fn().mockResolvedValue({ ok: true }) }, // no key
};

const result = await handler(event, ctx);
Expand Down
13 changes: 10 additions & 3 deletions src/core/extensions/compaction-safeguard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,13 +620,17 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
return { cancel: true };
}

const apiKey = await ctx.modelRegistry.getApiKey(model);
if (!apiKey) {
const requestAuth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
if (!requestAuth.ok || !requestAuth.apiKey) {
console.warn(
"[compaction-safeguard] No API key available; cancelling compaction to preserve history.",
`[compaction-safeguard] No API key available; cancelling compaction to preserve history.${
requestAuth.ok ? "" : ` ${requestAuth.error}`
}`,
);
return { cancel: true };
}
const apiKey = requestAuth.apiKey;
const headers = requestAuth.headers;

try {
const contextWindowTokens = resolveContextWindowTokens(model);
Expand Down Expand Up @@ -683,6 +687,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
messages: pruned.droppedMessagesList,
model,
apiKey,
headers,
signal,
reserveTokens: Math.max(1, Math.floor(preparation.settings.reserveTokens)),
maxChunkTokens: droppedMaxChunkTokens,
Expand Down Expand Up @@ -748,6 +753,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
messages: messagesToSummarize,
model,
apiKey,
headers,
signal,
reserveTokens,
maxChunkTokens,
Expand All @@ -770,6 +776,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
messages: turnPrefixMessages,
model,
apiKey,
headers,
signal,
reserveTokens,
maxChunkTokens,
Expand Down
2 changes: 2 additions & 0 deletions src/core/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ Call these tools before doing anything else:

One cluster: use directly. Multiple: ask user which to use, pass \`--kubeconfig=<name>\` (name, not path).

Namespace scope: respect the user's requested scope exactly. If the user says "current namespace", use the current kubeconfig context namespace when it is known; if it is unknown, ask for the namespace instead of broadening to \`-A\` / \`--all-namespaces\`. Use all-namespaces only when the user explicitly asks for cluster-wide evidence or when a skill specifically requires it, and prefer narrow selectors or namespaces first.

**Reaching non-K8s hosts**: To run commands on a host bound via \`host_list\`, use \`host_exec\` (single command) or \`host_script\` (skill script via SSH stdin). The \`bash\` (restricted-bash) tool does NOT permit \`ssh\`/\`scp\`/\`sftp\`/\`sshpass\` — you cannot assemble your own ssh invocation. Only \`host_exec\`/\`host_script\` carry a valid SSH credential.

### Step 2 — Skill check (HARD GATE before every action)
Expand Down
1 change: 1 addition & 0 deletions src/tools/cmd-exec/restricted-bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ In local mode, text processing commands (grep, cut, sort, etc.) only work after
All other binaries are blocked — except bash/sh/python3 invoking scripts under skills/.

Multi-cluster: when multiple kubeconfigs are available, pass --kubeconfig=<name> (credential name, NOT file path) to select a cluster. Use cluster_list to discover available names. Never use full file paths in --kubeconfig — they will be blocked. Do NOT use KUBECONFIG= env prefix — only the --kubeconfig=<name> flag is supported.
Namespace scope: do not broaden a namespace-scoped request to -A/--all-namespaces. If the user asks for the current namespace and the current kubeconfig namespace is not known, ask for the namespace or run a narrow context/namespace check first. Use -A only for explicitly cluster-wide requests, preferably with selectors or compact output.

Rate protection rules for kubectl:
- "kubectl logs" requires --tail=<N> or --since=<duration>; bare logs without these will be rejected.
Expand Down
Loading