Skip to content
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ All notable changes to the PiLot Studio for VS Code extension will be documented

### Added

- **System prompt file editing from the GUI** (Settings → Configuration Files): "Open SYSTEM.md" and "Open APPEND_SYSTEM.md" buttons create the file in PI's agent directory (default `~/.pi/agent`, configurable via `pi-agent.agentDir`) if missing and open it in a VS Code editor tab. SYSTEM.md replaces PI's default system prompt for new sessions (with a confirmation before first creation); APPEND_SYSTEM.md appends to it. A warning is shown in Settings when `pi-agent.systemPrompt` / `pi-agent.appendSystemPrompts` override these files.

### Fixed

- The `pi-agent.agentDir` setting was never applied; it now takes effect on extension activation (window reload required after changing it).
- Test launcher silently exiting without running tests on distro VS Code installs where `code` is a wrapper script.

- **PI Light Mode** toggle (Settings → Agent Behavior, or the `pi-agent.lightMode` setting) for running pi in a reduced mode ideal for local LLMs via llama.cpp — equivalent to `pi --no-skills --no-extensions --no-context-files --no-prompt-templates --no-themes --tools read,bash,edit,write`. Overrides the individual discovery toggles and restricts tools to `read`, `bash`, `edit`, `write` when the tool preset is `default`; toggling restarts the current session with its history preserved.

## [2.4.1] - 2026-09-08
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@
"type": "string",
"default": "",
"order": 2,
"markdownDescription": "Directory where pi stores its auth, models, sessions, and other agent files. Leave empty to use `~/.pi/agent`."
"markdownDescription": "Directory where pi stores its auth, models, sessions, and other agent files. Leave empty to use `~/.pi/agent`. Changes take effect after a window reload (applied at extension activation)."
},
"pi-agent.sessionDir": {
"type": "string",
Expand Down
7 changes: 7 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ import { startUpdateChecker } from "./update-checker.js";
export async function activate(context: vscode.ExtensionContext) {
const config = vscode.workspace.getConfiguration("pi-agent");

// pi's SDK reads the agent directory only from PI_CODING_AGENT_DIR, so
// bridge the setting into it (applied at activation; reload to apply).
const agentDir = config.get<string>("agentDir", "").trim();
if (agentDir) {
process.env.PI_CODING_AGENT_DIR = agentDir;
}

const provider = new PiAgentProvider(context, {
defaultModel: config.get("defaultModel", "anthropic/claude-sonnet-4-5"),
defaultProvider: config.get("defaultProvider", "anthropic"),
Expand Down
31 changes: 25 additions & 6 deletions src/message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@ import * as vscode from "vscode";
import * as os from "node:os";
import * as path from "node:path";
import * as fs from "node:fs";
import { type ProviderApi } from "./protocol/types.js";
import { type ProviderApi, type ConfigFileKey } from "./protocol/types.js";

/** Keys openConfigFile accepts; must mirror ConfigFileKey. */
const OPEN_CONFIG_FILES: readonly string[] = [
"auth",
"models",
"settings",
"system-prompt",
"append-system-prompt",
] satisfies readonly ConfigFileKey[];

export class MessageHandler {
constructor(private provider: ProviderApi) {}
Expand Down Expand Up @@ -309,17 +318,17 @@ export class MessageHandler {

case "openConfigFile": {
const file = message.data?.file;
const allowed = ["auth", "models", "settings"];
if (typeof file !== "string" || !allowed.includes(file)) {
if (
typeof file !== "string" ||
!OPEN_CONFIG_FILES.includes(file)
) {
result = {
error: `Invalid config file: ${String(file)}`,
};
break;
}
await this.withErrorReporting(() =>
this.provider.openConfigFile(
file as "auth" | "models" | "settings",
),
this.provider.openConfigFile(file as ConfigFileKey),
);
result = { success: true };
break;
Expand Down Expand Up @@ -374,6 +383,16 @@ export class MessageHandler {
await this.provider.sendSkillsList();
break;

case "getSystemPromptOverrides": {
const overrides = this.provider.getSystemPromptOverrides();
this.provider.webview?.postMessage({
type: "system-prompt-overrides-changed",
data: overrides,
});
result = overrides;
break;
}

case "getSkillDiscovery": {
const enabled = this.provider.getSkillDiscovery();
this.provider.webview?.postMessage({
Expand Down
69 changes: 60 additions & 9 deletions src/pi-agent-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type PromptTemplate,
} from "@earendil-works/pi-coding-agent";
import { MessageHandler } from "./message-handler.js";
import { type ConfigFileKey } from "./protocol/types.js";
import { VoiceManager } from "./voice-manager.js";
import {
type ImageContent,
Expand Down Expand Up @@ -48,6 +49,19 @@ import { serializeMessages } from "./message-serializer.js";
// THINKING_LEVELS is imported from ./model-registry-handler.js so the host and
// webview share a single ordered source of truth for the thinking-level list.

// Key → filename → creation policy behind openConfigFile.
const CONFIG_FILES: Record<
ConfigFileKey,
{ fileName: string; initialContent: string; confirmCreate?: boolean }
> = {
auth: { fileName: "auth.json", initialContent: "{}" },
models: { fileName: "models.json", initialContent: "{}" },
settings: { fileName: "settings.json", initialContent: "{}" },
// Creating it empty replaces pi's built-in system prompt, so confirm.
"system-prompt": { fileName: "SYSTEM.md", initialContent: "", confirmCreate: true },
"append-system-prompt": { fileName: "APPEND_SYSTEM.md", initialContent: "" },
};

// ── OAuth login interaction shapes ───────────────────────────────────────────
// Structural mirrors of @earendil-works/pi-ai's AuthPrompt/AuthEvent (the SDK
// entry point does not re-export them). Keep in sync with pi-ai 0.84.
Expand Down Expand Up @@ -91,6 +105,8 @@ export const piAgentProviderInternals = {
createAgentSession,
getAgentDir,
unlinkFile: (path: string) => fs.unlink(path),
// Tests stub filesystem access through this seam; ESM namespaces are frozen.
existsFile: (path: string) => existsSync(path),
mkdir: (dir: string, options?: { recursive?: boolean }) =>
fs.mkdir(dir, options),
writeFile: (
Expand Down Expand Up @@ -2669,21 +2685,39 @@ this.modelRegistryHandler.invalidateCliModelIdsCache();
}
}

async openConfigFile(file: "auth" | "models" | "settings"): Promise<void> {
/** Open a PI config/prompt file in an editor tab, creating it if missing. */
async openConfigFile(file: ConfigFileKey): Promise<void> {
if (!this.isInitialized) {
await this.initialize();
}
const fileName = `${file}.json`;
const spec = CONFIG_FILES[file];
const agentDir = piAgentProviderInternals.getAgentDir();
const filePath = path.join(agentDir, fileName);
const filePath = path.join(agentDir, spec.fileName);

await piAgentProviderInternals.mkdir(agentDir, { recursive: true });
// Write only if the file does not already exist (flag "wx").
try {
await piAgentProviderInternals.writeFile(filePath, "{}", { flag: "wx" });
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException)?.code !== "EEXIST") {
throw error;

if (!piAgentProviderInternals.existsFile(filePath)) {
if (spec.confirmCreate) {
const choice = await vscode.window.showInformationMessage(
`Creating ${spec.fileName} will replace pi's default system prompt for new sessions. Continue?`,
{ modal: false },
"Create",
);
if (choice !== "Create") {
return;
}
}
// Write only if the file does not already exist.
try {
await piAgentProviderInternals.writeFile(
filePath,
spec.initialContent,
{ flag: "wx" },
);
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException)?.code !== "EEXIST") {
throw error;
}
}
}

Expand Down Expand Up @@ -3097,6 +3131,23 @@ this.modelRegistryHandler.invalidateCliModelIdsCache();
return !config.get<boolean>("disableSkillDiscovery", false);
}

/**
* Which pi-agent.* settings override PI's SYSTEM.md / APPEND_SYSTEM.md
* discovery: when set, the resource loader never consults the files.
*/
getSystemPromptOverrides(): { systemPrompt: boolean; appendSystemPrompts: boolean } {
const config = vscode.workspace.getConfiguration("pi-agent");
const systemPrompt = config.get<string | null>("systemPrompt", null);
const appendSystemPrompts = config.get<string[]>(
"appendSystemPrompts",
[],
);
return {
systemPrompt: !!systemPrompt,
appendSystemPrompts: appendSystemPrompts.length > 0,
};
}

setSkillDiscovery(enabled: boolean): void {
const config = vscode.workspace.getConfiguration("pi-agent");
config
Expand Down
12 changes: 11 additions & 1 deletion src/protocol/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ export interface ProviderMessage {
isError?: boolean;
}

/** Keys accepted by openConfigFile; filenames/creation policy live in the provider. */
export type ConfigFileKey =
| "auth"
| "models"
| "settings"
| "system-prompt"
| "append-system-prompt";

// ── Provider API interface — used by MessageHandler to avoid importing
// the full PiAgentProvider class (breaks circular dependency) ────────

Expand Down Expand Up @@ -115,7 +123,7 @@ export interface ProviderApi {
api?: string;
apiKey?: string;
}): Promise<Array<{ id: string; name?: string }>>;
openConfigFile(file: "auth" | "models" | "settings"): Promise<void>;
openConfigFile(file: ConfigFileKey): Promise<void>;
toggleFavorite(modelId: string, isFavorite: boolean): Promise<string[]>;
listSessions(): Promise<
Array<{
Expand Down Expand Up @@ -163,6 +171,8 @@ export interface ProviderApi {
sendSkillsList(): Promise<void>;
getSkillDiscovery(): boolean;
setSkillDiscovery(enabled: boolean): void;
/** Whether pi-agent.* settings currently override SYSTEM.md / APPEND_SYSTEM.md. */
getSystemPromptOverrides(): { systemPrompt: boolean; appendSystemPrompts: boolean };
setExtraSkillPaths(paths: string[]): Promise<void>;
getExtraSkillPaths(): string[];
getLightMode(): boolean;
Expand Down
1 change: 1 addition & 0 deletions src/test/mocks/pi-sdk-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ export function resetVscodeMocks(): void {
(vscode.workspace.findFiles as any) = async () => [];
(vscode.workspace.openTextDocument as any) = async () => ({ getText: () => "" });
(vscode.workspace.fs.createDirectory as any) = async () => {};
(vscode.workspace.fs.readFile as any) = async () => new Uint8Array();
(vscode.workspace.fs.writeFile as any) = async () => {};
(vscode.workspace.onDidChangeConfiguration as any) = new (vscode.EventEmitter as any)().event;

Expand Down
13 changes: 9 additions & 4 deletions src/test/runTest.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs';
import { existsSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { pathToFileURL } from 'node:url';
Expand Down Expand Up @@ -37,9 +37,14 @@ function getVscodeExecutablePath(): string | undefined {
} else {
const linuxPaths = ['/usr/bin/code', '/usr/local/bin/code', '/snap/bin/code'];
for (const exePath of linuxPaths) {
if (existsSync(exePath)) {
return exePath;
}
if (!existsSync(exePath)) continue;
// Distro `code` is a shell wrapper (…/bin/code) that re-forks the GUI via
// cli.js; the test runner then loses process ownership and the run exits 0
// without ever starting mocha (observed flake). Prefer the real Electron
// binary in the resolved install dir when it exists.
const wrapper = realpathSync(exePath);
const electronBinary = path.join(path.dirname(path.dirname(wrapper)), 'code');
return existsSync(electronBinary) ? electronBinary : exePath;
}
}

Expand Down
15 changes: 10 additions & 5 deletions src/test/suite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ function installVscodeFacade(): void {
((globalThis as any).__vscodeFacade as Record<string, any> | undefined) ?? {};
const isPlainObject = (v: any): boolean =>
!!v && typeof v === 'object' && Object.getPrototypeOf(v) === Object.prototype;
// A frozen plain object cannot take mock writes either. VS Code 1.137 ships
// workspace.fs as exactly that (frozen, Object.prototype), so treat any
// non-extensible value as a real namespace: never copy it onto the facade
// by reference, always replace it with a fresh object.
const isMockable = (v: any): boolean => isPlainObject(v) && Object.isExtensible(v);
// Nested API namespaces are composed explicitly below; copying them here
// would put the REAL frozen namespaces (getter-only props on newer VS Code)
// on the facade and break every mock assignment.
Expand Down Expand Up @@ -90,7 +95,7 @@ function installVscodeFacade(): void {
if (
!isCtor &&
typeof value === 'object' &&
!isPlainObject(value)
!isMockable(value)
) {
continue;
}
Expand All @@ -110,16 +115,16 @@ function installVscodeFacade(): void {
};
assignProps(facade, real, false);
const ensureObj = (parent: any, name: string): any => {
// Never keep a non-plain object (e.g. the real frozen API namespace):
// mocks must be able to assign arbitrary props on it.
if (!isPlainObject(parent[name])) {
// Never keep a real namespace (non-plain, or frozen like VS Code 1.137's
// workspace.fs): mocks must be able to assign arbitrary props on it.
if (!isMockable(parent[name])) {
try {
parent[name] = {};
} catch {
/* unreplaceable: fall through */
}
}
return isPlainObject(parent[name]) ? parent[name] : {};
return isMockable(parent[name]) ? parent[name] : {};
};
assignProps(ensureObj(facade, 'window'), real.window, true);
const ws = ensureObj(facade, 'workspace');
Expand Down
Loading
Loading