Skip to content
Merged
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,8 @@ macOS/Linux arm64 与 x64 缺少二进制时,OpenPI 会从官方 Release 下

无参数时,OpenPI 展示当前状态并引导修改;带自然语言时只改指定项:

Plan 模式下需先退出规划,再通过 `/openpi-setup` 修改配置。Web 设置页会禁用相关修改入口,并在会话空闲时提供退出按钮;退出仅切换状态,不调用模型或开始实施。Setup 在接收命令和投递排队请求时都检查当前 Session 的 Plan 状态。设置页依据实际工具回执反馈保存、设置未变化、失败或取消;回合结束或模型声称成功都不能替代保存回执。

<!-- config-contract: capabilities.discovery suggestions.enabled suggestions.model workflows.concurrency workflows.maxAgentCalls childExecutions childExecutions.maxActive ui.webTheme ui.webChatWidth ui.webChatFontSize ui.webExpandThinking ui.showHeader ui.customFooter ui.footerStyle ui.footerLines ui.subagentResultDisplay ui.bashToolDisplay ui.fileMutationDisplay postEdit.command subagents.roleModels -->

```text
Expand Down
2 changes: 2 additions & 0 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ Pi will load the extensions and theme the next time it starts. OpenPI's Backgrou

The terminal theme above remains Pi-owned. OpenPI Web has separate package-owned appearance preferences configured only through `/openpi-setup`: `system`, `light`, `dark`, `mist`, `rose`, and `pine` themes; chat width from 820-2000px; chat font size from 12-24px; and whether thinking blocks start expanded. `system`, 820px, 14px, and collapsed are the defaults.

Exit Plan mode before starting `/openpi-setup`, including from Web settings. The command checks the current Session branch before starting a setup turn, and queued requests are checked again before delivery. Web disables package configuration controls during planning and offers the existing Plan exit action once the Session is idle; exiting does not start a model turn or authorize implementation. Settings reports the latest setup episode from persisted tool receipts, including unchanged saves, failures, cancellation, and an unconfirmed save. A completed turn or an assistant's claim alone is not proof that configuration was saved.

## Configure this package

The `compact` footer preset resolves to `plain` style with the default single-row layout; it is not a separate stored style. Applying it to the default footer does not change its appearance. Successful setup receipts distinguish saving the document from changing effective settings and list only fields that actually changed. Keeping defaults without invoking the writer does not create a file.
Expand Down
44 changes: 35 additions & 9 deletions extensions/plan-mode/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,16 +631,42 @@ export default function planMode(pi: ExtensionAPI) {
});
};

// Mode selection does not create a turn. Supply the current stance when
// Pi actually starts the next user/background turn, including after restore.
pi.on("before_agent_start", (event) => {
if (!planning) return;
// Custom-message turns (including Setup) bypass before_agent_start. Project
// the current stance at every provider call, without persisting a message or
// leaving an obsolete restriction in Pi's cached system prompt.
pi.on("context", (event, ctx) => {
if (!planning) {
if (
!ctx.sessionManager
.getBranch()
.some(
(entry) =>
entry.type === "custom" &&
entry.customType === PLAN_MODE_STATE_ENTRY,
)
)
return;
}
const content = !planning
? "Current Session state: Plan mode is inactive. Earlier Plan-mode restrictions in this conversation are historical and no longer apply. Exiting Plan mode does not authorize implementation of a previous plan; follow the user's current request and normal permissions."
: readyPlan
? "The plan is ready. All tools remain blocked until the user explicitly chooses a Plan action or exits planning. Do not start implementation."
: BLOCK_REASON;
return {
systemPrompt: `${event.systemPrompt}\n\n${
readyPlan
? "The plan is ready. All tools remain blocked until the user explicitly chooses a Plan action or exits planning. Do not start implementation."
: BLOCK_REASON
}`,
messages: [
...event.messages.filter(
(message) =>
message.role !== "custom" ||
message.customType !== "openpi-plan-context",
),
{
role: "custom" as const,
customType: "openpi-plan-context",
content,
display: false,
timestamp: 0,
},
],
};
});

Expand Down
19 changes: 18 additions & 1 deletion extensions/setup/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { StringEnum } from "@earendil-works/pi-ai";
import { restorePlanModeState } from "../plan-mode/persisted-state.ts";
import { applySetupConfiguration } from "../shared/setup-apply.ts";
import type {
ExtensionAPI,
Expand Down Expand Up @@ -257,6 +258,17 @@ export default function openPiSetup(pi: ExtensionAPI) {
const dispatchNextRequest = (ctx: ExtensionContext) => {
const request = pendingRequests.shift();
if (!request) return;
if (restorePlanModeState(ctx.sessionManager.getBranch()).planning) {
resetEpisode();
pi.sendMessage({
customType: "openpi-setup-closed",
content:
"Setup was not started. Exit Plan mode before changing OpenPI settings, then retry /openpi-setup.",
display: true,
details: { reason: "plan_mode_active" },
});
return;
}
if (!showConfigureTool(pi)) {
resetEpisode();
if (ctx.hasUI) {
Expand Down Expand Up @@ -649,12 +661,17 @@ export default function openPiSetup(pi: ExtensionAPI) {
);
return {
content: [{ type: "text", text: receipt }],
details: config,
details: { ...config, setupReceipt: { changed } },
};
},
});

const setupHandler = async (args: string, ctx: ExtensionCommandContext) => {
if (restorePlanModeState(ctx.sessionManager.getBranch()).planning) {
throw new Error(
"Exit Plan mode before changing OpenPI settings, then retry /openpi-setup. No setup turn was started.",
);
}
const request = args.trim();
const inspected = inspectSetupConfig();
const diagnostics = formatSetupDiagnostics(inspected);
Expand Down
36 changes: 24 additions & 12 deletions tests/extensions/git-info/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ test("automatic refresh with a spaced fixture path stays local until /pr explici
// use copied Node executables with a per-process command shim instead.
writeFileSync(
commandRunner,
`const { appendFileSync } = require("node:fs");
`const { appendFileSync, writeFileSync } = require("node:fs");
// This preload must exit before Node tries to load Git's arguments as a file.
// Windows pipes are asynchronous: flush the fixture output before exiting.
const output = (text) => writeFileSync(1, text);
const command = /[\\\\/]git\\.exe$/i.test(process.execPath)
? "git"
: /[\\\\/]gh\\.exe$/i.test(process.execPath)
Expand All @@ -54,15 +57,15 @@ const args = [
];
if (command === "git") {
const key = \`\${args[0] ?? ""} \${args[1] ?? ""}\`;
if (key === "rev-parse --is-inside-work-tree") process.stdout.write("true\\n");
else if (key === "branch --show-current") process.stdout.write("main\\n");
else if (key === "rev-parse --short") process.stdout.write("abc123\\n");
else if (key === "status --porcelain=v1") process.stdout.write(" M local.ts\\n?? new.ts\\n");
if (key === "rev-parse --is-inside-work-tree") output("true\\n");
else if (key === "branch --show-current") output("main\\n");
else if (key === "rev-parse --short") output("abc123\\n");
else if (key === "status --porcelain=v1") output(" M local.ts\\n?? new.ts\\n");
else process.exitCode = 2;
process.exit();
} else if (command === "gh") {
appendFileSync(process.env.GH_CALL_LOG, args.join(" ") + "\\n");
process.stdout.write("{\\"number\\":42,\\"url\\":\\"https://example.test/pr/42\\",\\"state\\":\\"OPEN\\",\\"isDraft\\":false}\\n");
output("{\\"number\\":42,\\"url\\":\\"https://example.test/pr/42\\",\\"state\\":\\"OPEN\\",\\"isDraft\\":false}\\n");
process.exit();
}
`,
Expand Down Expand Up @@ -122,11 +125,7 @@ printf '%s\\n' '{"number":42,"url":"https://example.test/pr/42","state":"OPEN","
events: {
on: () => () => undefined,
emit: (channel: string, value: unknown) => {
if (
channel === GIT_INFO_CHANNEL &&
isGitInfoState(value) &&
value.isRepository
) {
if (channel === GIT_INFO_CHANNEL && isGitInfoState(value)) {
resolveLocal(value);
}
},
Expand Down Expand Up @@ -165,6 +164,11 @@ printf '%s\\n' '{"number":42,"url":"https://example.test/pr/42","state":"OPEN","
);
}),
]).finally(() => clearTimeout(timeoutHandle));
assert.equal(
local.isRepository,
true,
"the initial Git fixture refresh must recognize the repository",
);
assert.equal(local.branch, "main");
assert.equal(local.changedFiles, 2);
assert.equal(local.pullRequest, null);
Expand All @@ -183,7 +187,15 @@ printf '%s\\n' '{"number":42,"url":"https://example.test/pr/42","state":"OPEN","
else process.env.GH_CALL_LOG = previousLog;
if (previousNodeOptions === undefined) delete process.env.NODE_OPTIONS;
else process.env.NODE_OPTIONS = previousNodeOptions;
rmSync(root, { recursive: true, force: true });
// Windows can keep a just-exited copied `gh.exe` handle open briefly.
// Retry the bounded fixture cleanup instead of turning that OS race into
// a failure after all assertions have passed.
rmSync(root, {
recursive: true,
force: true,
maxRetries: process.platform === "win32" ? 10 : 0,
retryDelay: 100,
});
}
});

Expand Down
42 changes: 33 additions & 9 deletions tests/extensions/plan-mode/control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,8 @@ test("mode-only control persists and gates tools without sending a model turn; r
);
assert.equal(h.emit("tool_call", { toolName: "read" }), undefined);
assert.match(
(
h.emit("before_agent_start", { systemPrompt: "base" }) as {
systemPrompt: string;
}
).systemPrompt,
/base[\s\S]*Plan mode is active/,
JSON.stringify(h.emit("context", { messages: [] })),
/Plan mode is active/,
);
const count = h.branch.length;
h.set(true);
Expand All @@ -102,16 +98,44 @@ test("mode-only control persists and gates tools without sending a model turn; r
assert.equal(projectPlanControl(h.branch).hasPrompt, true);
h.set(false);
assert.equal(h.emit("tool_call", { toolName: "write" }), undefined);
assert.equal(
h.emit("before_agent_start", { systemPrompt: "base" }),
undefined,
assert.match(
JSON.stringify(h.emit("context", { messages: [] })),
/Plan mode is inactive/,
);
assert.equal(projectPlanControl(h.branch).hasPrompt, false);
assert.deepEqual(h.messages, []);
h.emit("session_shutdown");
assert.throws(() => h.set(true), /unavailable/);
});

test("inactive context follows the current branch and never authorizes a previous plan", () => {
const h = harness();
assert.equal(h.emit("context", { messages: [] }), undefined);
h.set(true);
h.set(false);
h.emit("session_tree");
const input = {
messages: [
{
role: "custom",
customType: "openpi-setup-request",
content: "Change theme",
},
],
};
const projection = h.emit("context", input);
const prompt = JSON.stringify(projection);
assert.match(prompt, /Plan mode is inactive/);
assert.match(prompt, /does not authorize implementation/);
assert.equal(input.messages.length, 1);
assert.deepEqual(h.emit("context", projection), projection);
assert.deepEqual(h.messages, []);
h.branch.length = 0;
h.emit("session_tree");
assert.equal(h.emit("context", { messages: [] }), undefined);
h.emit("session_shutdown");
});

test("busy, stale, ready and failed persistence never silently open the Plan gate", () => {
const h = harness();
h.set(true);
Expand Down
51 changes: 51 additions & 0 deletions tests/extensions/setup/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,60 @@ const { SETUP_CONFIG_PATH, loadSetupConfig } = await import(

after(() => rmSync(setupAgentDir, { recursive: true, force: true }));

test("Setup rejects planning, ready and invalid branches before starting a model turn, including the alias", async () => {
for (const status of ["planning", "ready", "invalid"]) {
const branch: unknown[] = [
{
type: "custom",
customType: "my-pi-setup-plan-mode-state",
data: {
version: 1,
status,
...(status === "ready" ? { plan: "Review this plan" } : {}),
},
},
];
const h = visibilityHarness({ branch });
await h.emit("session_start");
for (const name of ["openpi-setup", "my-pi-setup"]) {
await assert.rejects(
h.runCommand(name, "use dark theme"),
/Exit Plan mode/,
);
}
assert.equal(h.isActive(), false);
assert.equal(h.setupRequests().length, 0);
branch.push({
type: "custom",
customType: "my-pi-setup-plan-mode-state",
data: { version: 1, status: "inactive" },
});
await h.runCommand("openpi-setup", "use dark theme");
assert.equal(h.setupRequests().length, 1);
}
});

type Handler = (
event: Record<string, unknown>,
ctx: ExtensionContext,
) => unknown;

test("queued setup rechecks the branch before exposing its writer", async () => {
const branch: unknown[] = [];
const h = visibilityHarness({ branch, idle: false });
await h.emit("session_start");
await h.runCommand("openpi-setup", "use dark theme");
branch.push({
type: "custom",
customType: "my-pi-setup-plan-mode-state",
data: { version: 1, status: "planning" },
});
await h.emit("agent_settled");
assert.equal(h.isActive(), false);
assert.equal(h.setupRequests().length, 0);
assert.match(JSON.stringify(h.closures()), /plan_mode_active/);
});

interface CapturedSetupTool {
readonly name: string;
readonly parameters: unknown;
Expand All @@ -63,6 +112,7 @@ function visibilityHarness(
initialActive?: string[];
mode?: ExtensionCommandContext["mode"];
idle?: boolean;
branch?: unknown[];
setupSourcePath?: string;
} = {},
) {
Expand Down Expand Up @@ -145,6 +195,7 @@ function visibilityHarness(
mode: options.mode ?? "rpc",
hasUI: false,
cwd: "/tmp/setup-visibility-test",
sessionManager: { getBranch: () => options.branch ?? [] },
isIdle: () => idle,
model: undefined,
ui: {
Expand Down
8 changes: 6 additions & 2 deletions tests/web/composer-context.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,12 @@ it("validates and inserts a workspace file reference at the caret", async () =>
await waitFor(() =>
expect(release).toHaveBeenCalledWith("session-1", "artifact-1"),
);
expect(document.activeElement).toBe(input);
expect(input.selectionStart).toBe(`Review \`${reference}\``.length);
// Composer restores focus after the dialog closes, in a deferred effect.
// Artifact release and text insertion can finish before that timer fires.
await waitFor(() => {
expect(document.activeElement).toBe(input);
expect(input.selectionStart).toBe(`Review \`${reference}\``.length);
});
});

it("keeps a failed active-session reference editable", async () => {
Expand Down
Loading
Loading