Compare commits

...
Author SHA1 Message Date
StanPonomarev f6cae21bfa feat: make playwright optional
CI / verify (pull_request) Successful in 8s
2026-09-15 10:18:43 +02:00
StanPonomarev 1bc5c329a8 Merge pull request 'fix: restore quota name' (#7) from fix/restore-quota-name into main
Release / release (push) Successful in 10s
Reviewed-on: #7
2026-09-15 08:15:15 +02:00
StanPonomarev c973738b60 fix: restore quota name
CI / verify (pull_request) Successful in 10s
2026-09-15 08:14:37 +02:00
StanPonomarev c24e38bbf2 Merge pull request 'fix: restore session usage' (#6) from fix/restore-session-usage into main
Release / release (push) Successful in 9s
Reviewed-on: #6
2026-09-14 21:34:25 +02:00
StanPonomarev 15650d9bb7 fix: restore session usage
CI / verify (pull_request) Successful in 9s
2026-09-14 21:34:02 +02:00
11 changed files with 121 additions and 37 deletions
+9 -1
View File
@@ -12,6 +12,12 @@ Run the installer without sudo:
curl -fsSL https://git.krtss.de/StanPonomarev/stanp-opencode/raw/branch/main/install.sh | bash curl -fsSL https://git.krtss.de/StanPonomarev/stanp-opencode/raw/branch/main/install.sh | bash
``` ```
Pass `--playwright` to let the plugin automatically configure a Playwright MCP server:
```sh
curl -fsSL https://git.krtss.de/StanPonomarev/stanp-opencode/raw/branch/main/install.sh | bash -s -- --playwright
```
Rerun the command to upgrade to the latest published release. Rerun the command to upgrade to the latest published release.
## Install from a local checkout ## Install from a local checkout
@@ -22,6 +28,8 @@ This requires Bash, jq, and Bun 1.3.14+:
bash /path/to/stanp-opencode/install-local.sh bash /path/to/stanp-opencode/install-local.sh
``` ```
Add `--playwright` to this command to enable automatic Playwright MCP configuration.
The script installs dependencies and builds the checkout before installing it. The script installs dependencies and builds the checkout before installing it.
After either installation, restart `opencode2` or run `opencode2 service restart` for an existing service. Connect OpenAI and select `coordinate`. The required models must be available for the workers to run. After either installation, restart `opencode2` or run `opencode2 service restart` for an existing service. Connect OpenAI and select `coordinate`. The required models must be available for the workers to run.
@@ -36,7 +44,7 @@ For an installed plugin, use `./plugins/stanp-opencode` as the plugin package pa
## Playwright ## Playwright
The plugin prefers a local Playwright MCP installation. If ordinary Playwright is installed, it can download and run the MCP package with `npx`; otherwise Docker is required for the automatic fallback. Automatically configured servers allow five minutes for startup because the first launch may need to download a package or container image. Docker reuses its cached image on later launches. Existing `playwright` configuration, including its timeout, is preserved. When installed with `--playwright`, the plugin prefers a local Playwright MCP installation. If ordinary Playwright is installed, it can download and run the MCP package with `npx`; otherwise Docker is required for the automatic fallback. Automatically configured servers allow five minutes for startup because the first launch may need to download a package or container image. Docker reuses its cached image on later launches. Existing `playwright` configuration, including its timeout, is preserved.
If `opencode mcp list` reports `MCP error -32001: Request timed out`, prefetch the Docker fallback with `docker pull mcr.microsoft.com/playwright/mcp`, or install `@playwright/mcp` in the project so the plugin can launch it directly. Then reconnect the server from `/mcps` or restart OpenCode. A custom `playwright` entry under `mcp.servers` can also set its own `timeout.startup` in milliseconds. If `opencode mcp list` reports `MCP error -32001: Request timed out`, prefetch the Docker fallback with `docker pull mcr.microsoft.com/playwright/mcp`, or install `@playwright/mcp` in the project so the plugin can launch it directly. Then reconnect the server from `/mcps` or restart OpenCode. A custom `playwright` entry under `mcp.servers` can also set its own `timeout.startup` in milliseconds.
+16 -3
View File
@@ -1,6 +1,15 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
playwright=false
if (($#)); then
if [[ "$#" -ne 1 || "$1" != "--playwright" ]]; then
printf 'Usage: %s [--playwright]\n' "$0" >&2
exit 2
fi
playwright=true
fi
for command in bun jq; do for command in bun jq; do
command -v "$command" >/dev/null || { printf 'Required command not found: %s\n' "$command" >&2; exit 1; } command -v "$command" >/dev/null || { printf 'Required command not found: %s\n' "$command" >&2; exit 1; }
done done
@@ -43,15 +52,19 @@ if [[ ! -e "$config_source" ]]; then
config_source="$install/empty-config.json" config_source="$install/empty-config.json"
printf '{}\n' > "$config_source" printf '{}\n' > "$config_source"
fi fi
jq -e ' jq -e --argjson playwright "$playwright" '
if type != "object" then error("opencode.json must contain an object") else . end if type != "object" then error("opencode.json must contain an object") else . end
| if (.plugins // []) | type != "array" then error("opencode.json plugins must be an array") else . end | if (.plugins // []) | type != "array" then error("opencode.json plugins must be an array") else . end
| .["$schema"] = (.["$schema"] // "https://opencode.ai/config.json") | .["$schema"] = (.["$schema"] // "https://opencode.ai/config.json")
| .plugins = (.plugins // []) | .plugins = (.plugins // [])
| if any(.plugins[]?; .package == "./plugins/stanp-opencode") then . else | if any(.plugins[]?; .package == "./plugins/stanp-opencode") then
if $playwright then
.plugins |= map(if .package == "./plugins/stanp-opencode" then .options = ((.options // {}) + { playwright: true }) else . end)
else . end
else
.plugins += [{ .plugins += [{
package: "./plugins/stanp-opencode", package: "./plugins/stanp-opencode",
options: { mode: "gpt", provider: "openai", lunaPriority: true } options: ({ mode: "gpt", provider: "openai", lunaPriority: true } + if $playwright then { playwright: true } else {} end)
}] }]
end end
' "$config_source" > "$install/opencode.json" ' "$config_source" > "$install/opencode.json"
+16 -3
View File
@@ -1,6 +1,15 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
playwright=false
if (($#)); then
if [[ "$#" -ne 1 || "$1" != "--playwright" ]]; then
printf 'Usage: %s [--playwright]\n' "$0" >&2
exit 2
fi
playwright=true
fi
for command in bun curl jq sha256sum tar; do for command in bun curl jq sha256sum tar; do
command -v "$command" >/dev/null || { printf 'Required command not found: %s\n' "$command" >&2; exit 1; } command -v "$command" >/dev/null || { printf 'Required command not found: %s\n' "$command" >&2; exit 1; }
done done
@@ -57,15 +66,19 @@ if [[ ! -e "$config_source" ]]; then
config_source="$install/empty-config.json" config_source="$install/empty-config.json"
printf '{}\n' > "$config_source" printf '{}\n' > "$config_source"
fi fi
jq -e ' jq -e --argjson playwright "$playwright" '
if type != "object" then error("opencode.json must contain an object") else . end if type != "object" then error("opencode.json must contain an object") else . end
| if (.plugins // []) | type != "array" then error("opencode.json plugins must be an array") else . end | if (.plugins // []) | type != "array" then error("opencode.json plugins must be an array") else . end
| .["$schema"] = (.["$schema"] // "https://opencode.ai/config.json") | .["$schema"] = (.["$schema"] // "https://opencode.ai/config.json")
| .plugins = (.plugins // []) | .plugins = (.plugins // [])
| if any(.plugins[]?; .package == "./plugins/stanp-opencode") then . else | if any(.plugins[]?; .package == "./plugins/stanp-opencode") then
if $playwright then
.plugins |= map(if .package == "./plugins/stanp-opencode" then .options = ((.options // {}) + { playwright: true }) else . end)
else . end
else
.plugins += [{ .plugins += [{
package: "./plugins/stanp-opencode", package: "./plugins/stanp-opencode",
options: { mode: "gpt", provider: "openai", lunaPriority: true } options: ({ mode: "gpt", provider: "openai", lunaPriority: true } + if $playwright then { playwright: true } else {} end)
}] }]
end end
' "$config_source" > "$install/opencode.json" ' "$config_source" > "$install/opencode.json"
+1
View File
@@ -17,6 +17,7 @@ const assignment = z.strictObject({
const common = { const common = {
provider: z.string().trim().min(1).default("openai"), provider: z.string().trim().min(1).default("openai"),
lunaPriority: z.boolean().default(true), lunaPriority: z.boolean().default(true),
playwright: z.boolean().default(false),
}; };
export const settings = z.discriminatedUnion("mode", [ export const settings = z.discriminatedUnion("mode", [
+4 -2
View File
@@ -114,10 +114,12 @@ export default Plugin.define({
const assignments = selected.mode === "gpt" ? gptAgents : selected.agents; const assignments = selected.mode === "gpt" ? gptAgents : selected.agents;
let workers: (typeof workerNames)[number][] = []; let workers: (typeof workerNames)[number][] = [];
const active = new Set<string>(); const active = new Set<string>();
const playwrightCommand = await playwrightMcpCommand(context.location.directory); const playwrightCommand = selected.playwright
? await playwrightMcpCommand(context.location.directory)
: undefined;
await context.mcp.transform((editor) => { await context.mcp.transform((editor) => {
if (!editor.get("playwright")) if (playwrightCommand && !editor.get("playwright"))
editor.set("playwright", { editor.set("playwright", {
type: "local", type: "local",
command: playwrightCommand, command: playwrightCommand,
+15 -13
View File
@@ -3,42 +3,44 @@ import { createResource, createSignal, For, Show } from "solid-js";
import type { QuotaStatus, QuotaWindow } from "./quota.js"; import type { QuotaStatus, QuotaWindow } from "./quota.js";
import { QuotaRpc } from "./quota-rpc.js"; import { QuotaRpc } from "./quota-rpc.js";
function percent(value: number) {
return value < 0.1 && value > 0 ? "<0.1" : value.toFixed(value < 10 ? 1 : 0);
}
function duration(seconds: number) { function duration(seconds: number) {
if (seconds % 86_400 === 0) return `${seconds / 86_400}d`; if (seconds % 86_400 === 0) return `${seconds / 86_400}d`;
if (seconds % 3_600 === 0) return `${seconds / 3_600}h`; if (seconds % 3_600 === 0) return `${seconds / 3_600}h`;
return `${Math.round(seconds / 60)}m`; return `${Math.round(seconds / 60)}m`;
} }
function percent(value: number) {
return value < 0.1 && value > 0 ? "<0.1" : value.toFixed(value < 10 ? 1 : 0);
}
function resetTiming(resetsAt: number, now: number) { function resetTiming(resetsAt: number, now: number) {
if (!Number.isFinite(resetsAt) || resetsAt <= 0 || !Number.isFinite(now)) if (!Number.isFinite(resetsAt) || resetsAt <= 0 || !Number.isFinite(now)) return "unavailable";
return "reset unavailable";
const remaining = resetsAt - now; const remaining = resetsAt - now;
if (remaining <= 0) return "reset due"; if (remaining <= 0) return "due";
if (remaining < 60_000) return "resets in <1m"; if (remaining < 60_000) return "<1m";
const minutes = Math.floor(remaining / 60_000); const minutes = Math.floor(remaining / 60_000);
const days = Math.floor(minutes / 1_440); const days = Math.floor(minutes / 1_440);
if (days > 0) { if (days > 0) {
const hours = Math.floor((minutes % 1_440) / 60); const hours = Math.floor((minutes % 1_440) / 60);
return `resets in ${days}d${hours > 0 ? ` ${hours}h` : ""}`; return `${days}d${hours > 0 ? ` ${hours}h` : ""}`;
} }
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
if (hours > 0) { if (hours > 0) {
const rest = minutes % 60; const rest = minutes % 60;
return `resets in ${hours}h${rest > 0 ? ` ${rest}m` : ""}`; return `${hours}h${rest > 0 ? ` ${rest}m` : ""}`;
} }
return `resets in ${minutes}m`; return `${minutes}m`;
} }
export function formatQuotaWindow(window: QuotaWindow & { remaining: number }, now = Date.now()) { export function formatQuotaWindow(
window: QuotaWindow & { remaining: number; sessionConsumed: number },
now = Date.now(),
) {
const label = window.label const label = window.label
? `${window.label} ${duration(window.seconds)}` ? `${window.label} ${duration(window.seconds)}`
: duration(window.seconds); : duration(window.seconds);
return `${label}: ${percent(window.remaining)}% left · ${resetTiming(window.resetsAt, now)}`; return `${label}: ${percent(window.remaining)}% · ~${percent(window.sessionConsumed)}pp · ${resetTiming(window.resetsAt, now)}`;
} }
export async function loadQuotaStatus(load: () => Promise<QuotaStatus | null>) { export async function loadQuotaStatus(load: () => Promise<QuotaStatus | null>) {
+12
View File
@@ -199,6 +199,18 @@ test("reinstall replaces bundles while preserving existing settings and plugin o
expect(await readFile(join(setup.installed, "index.js"), "utf8")).toContain('return "second"'); expect(await readFile(join(setup.installed, "index.js"), "utf8")).toContain('return "second"');
}); });
test("enables Playwright only when requested", async () => {
const setup = await fixture();
const result = await run(
["bash", join(setup.checkout, "install-local.sh"), "--playwright"],
setup.cwd,
setup.env,
);
expect(result, result.stderr).toMatchObject({ exitCode: 0 });
const installed = JSON.parse(await readFile(join(setup.config, "opencode.json"), "utf8"));
expect(installed.plugins.at(-1).options.playwright).toBe(true);
});
for (const mode of ["dependency", "build", "validation", "config"] as const) { for (const mode of ["dependency", "build", "validation", "config"] as const) {
test(`${mode} failure leaves the previous installation and configuration untouched`, async () => { test(`${mode} failure leaves the previous installation and configuration untouched`, async () => {
const invalidConfig = mode === "config" ? { plugins: "not-an-array" } : undefined; const invalidConfig = mode === "config" ? { plugins: "not-an-array" } : undefined;
+17 -2
View File
@@ -44,7 +44,11 @@ async function run(command: string[], cwd: string, env: NodeJS.ProcessEnv) {
return { exitCode, stdout, stderr }; return { exitCode, stdout, stderr };
} }
async function install(mode: "native" | "checksum" | "legacy", configured = false) { async function install(
mode: "native" | "checksum" | "legacy",
configured = false,
playwright = false,
) {
const directory = await mkdtemp(join(tmpdir(), "stanp-install-")); const directory = await mkdtemp(join(tmpdir(), "stanp-install-"));
directories.push(directory); directories.push(directory);
const config = const config =
@@ -187,7 +191,11 @@ exec sha256sum "$@"
), ),
]); ]);
await Promise.all([chmod(join(bin, "curl"), 0o755), chmod(join(bin, "sha256sum"), 0o755)]); await Promise.all([chmod(join(bin, "curl"), 0o755), chmod(join(bin, "sha256sum"), 0o755)]);
const result = await run(["bash", installer], directory, env); const result = await run(
["bash", installer, ...(playwright ? ["--playwright"] : [])],
directory,
env,
);
return { directory, config, env, result }; return { directory, config, env, result };
} }
@@ -249,6 +257,13 @@ test("preserves an existing stanp-opencode configuration", async () => {
expect(installed.permissions).toEqual(jsonConfig.permissions); expect(installed.permissions).toEqual(jsonConfig.permissions);
}, 60_000); }, 60_000);
test("enables Playwright only when requested", async () => {
const { config, result } = await install("native", false, true);
expect(result, result.stderr).toMatchObject({ exitCode: 0 });
const installed = JSON.parse(await readFile(join(config, "opencode.json"), "utf8"));
expect(installed.plugins.at(-1).options.playwright).toBe(true);
}, 60_000);
for (const mode of ["checksum", "legacy"] as const) { for (const mode of ["checksum", "legacy"] as const) {
test(`${mode} failure preserves the installed plugin in a custom path containing spaces`, async () => { test(`${mode} failure preserves the installed plugin in a custom path containing spaces`, async () => {
const { directory, config, env, result } = await install(mode); const { directory, config, env, result } = await install(mode);
+18 -3
View File
@@ -442,7 +442,13 @@ test("playwright MCP prefers direct MCP, uses npx for Playwright, and preserves
await writeFile(executable, "#!/usr/bin/env node\n"); await writeFile(executable, "#!/usr/bin/env node\n");
await chmod(executable, 0o755); await chmod(executable, 0o755);
const local = await host(allModels, { provider: "test" }, {}, [], localProject); const local = await host(
allModels,
{ provider: "test", playwright: true },
{},
[],
localProject,
);
expect(local.mcpServers.get("playwright")).toEqual({ expect(local.mcpServers.get("playwright")).toEqual({
type: "local", type: "local",
command: [executable], command: [executable],
@@ -475,7 +481,13 @@ test("playwright MCP prefers direct MCP, uses npx for Playwright, and preserves
); );
await writeFile(ordinaryExecutable, "#!/usr/bin/env node\n"); await writeFile(ordinaryExecutable, "#!/usr/bin/env node\n");
await chmod(ordinaryExecutable, 0o755); await chmod(ordinaryExecutable, 0o755);
const ordinary = await host(allModels, { provider: "test" }, {}, [], ordinaryProject); const ordinary = await host(
allModels,
{ provider: "test", playwright: true },
{},
[],
ordinaryProject,
);
expect(ordinary.mcpServers.get("playwright")?.command).toEqual([ expect(ordinary.mcpServers.get("playwright")?.command).toEqual([
"npx", "npx",
"-y", "-y",
@@ -534,7 +546,7 @@ test("playwright MCP prefers direct MCP, uses npx for Playwright, and preserves
await chmod(fakePlaywright, 0o755); await chmod(fakePlaywright, 0o755);
expect((await playwrightMcpCommand(globalProject, ordinaryBin))[0]).toBe("docker"); expect((await playwrightMcpCommand(globalProject, ordinaryBin))[0]).toBe("docker");
const fallback = await host(); const fallback = await host(allModels, { provider: "test", playwright: true });
expect(fallback.mcpServers.get("playwright")).toEqual({ expect(fallback.mcpServers.get("playwright")).toEqual({
type: "local", type: "local",
command: ["docker", "run", "-i", "--rm", "--init", "mcr.microsoft.com/playwright/mcp"], command: ["docker", "run", "-i", "--rm", "--init", "mcr.microsoft.com/playwright/mcp"],
@@ -561,6 +573,9 @@ test("playwright MCP prefers direct MCP, uses npx for Playwright, and preserves
timeout: { startup: 12_345 }, timeout: { startup: 12_345 },
}); });
expect(existing.mcpServers.get("docs")?.codemode).toBe(true); expect(existing.mcpServers.get("docs")?.codemode).toBe(true);
const disabled = await host();
expect(disabled.mcpServers.has("playwright")).toBe(false);
} finally { } finally {
if (originalPath === undefined) delete process.env.PATH; if (originalPath === undefined) delete process.env.PATH;
else process.env.PATH = originalPath; else process.env.PATH = originalPath;
+12 -9
View File
@@ -424,11 +424,12 @@ test("sidebar text places reset timing beside latest remaining quota", () => {
label: null, label: null,
used: 25, used: 25,
remaining: 75, remaining: 75,
sessionConsumed: 1.5,
resetsAt: 100_000 + 2 * 86_400_000 + 5 * 3_600_000, resetsAt: 100_000 + 2 * 86_400_000 + 5 * 3_600_000,
}, },
100_000, 100_000,
), ),
).toBe("7d: 75% left · resets in 2d 5h"); ).toBe("7d: 75% · ~1.5pp · 2d 5h");
}); });
test("sidebar reset timing uses compact future units and handles boundaries", () => { test("sidebar reset timing uses compact future units and handles boundaries", () => {
@@ -441,31 +442,33 @@ test("sidebar reset timing uses compact future units and handles boundaries", ()
label: null, label: null,
used: 25, used: 25,
remaining: 75, remaining: 75,
sessionConsumed: 1.5,
resetsAt: now + resetIn, resetsAt: now + resetIn,
}, },
now, now,
); );
expect(format(2 * 3_600_000 + 15 * 60_000)).toBe("5h: 75% left · resets in 2h 15m"); expect(format(2 * 3_600_000 + 15 * 60_000)).toBe("5h: 75% · ~1.5pp · 2h 15m");
expect(format(3_600_000)).toBe("5h: 75% left · resets in 1h"); expect(format(3_600_000)).toBe("5h: 75% · ~1.5pp · 1h");
expect(format(15 * 60_000)).toBe("5h: 75% left · resets in 15m"); expect(format(15 * 60_000)).toBe("5h: 75% · ~1.5pp · 15m");
expect(format(60_000)).toBe("5h: 75% left · resets in 1m"); expect(format(60_000)).toBe("5h: 75% · ~1.5pp · 1m");
expect(format(59_999)).toBe("5h: 75% left · resets in <1m"); expect(format(59_999)).toBe("5h: 75% · ~1.5pp · <1m");
}); });
test("sidebar reset timing distinguishes stale and unavailable timestamps", () => { test("sidebar reset timing preserves labels and distinguishes stale and unavailable timestamps", () => {
const window = { const window = {
seconds: 18_000, seconds: 18_000,
id: "codex:primary", id: "codex:primary",
label: "GPT-5.6 Luna", label: "GPT-5.6 Luna",
used: 99.95, used: 99.95,
remaining: 0.05, remaining: 0.05,
sessionConsumed: 1.5,
}; };
expect(formatQuotaWindow({ ...window, resetsAt: 999 }, 1_000)).toBe( expect(formatQuotaWindow({ ...window, resetsAt: 999 }, 1_000)).toBe(
"GPT-5.6 Luna 5h: <0.1% left · reset due", "GPT-5.6 Luna 5h: <0.1% · ~1.5pp · due",
); );
expect(formatQuotaWindow({ ...window, resetsAt: Number.NaN }, 1_000)).toBe( expect(formatQuotaWindow({ ...window, resetsAt: Number.NaN }, 1_000)).toBe(
"GPT-5.6 Luna 5h: <0.1% left · reset unavailable", "GPT-5.6 Luna 5h: <0.1% · ~1.5pp · unavailable",
); );
}); });
+1 -1
View File
@@ -84,7 +84,7 @@ test("built quota slot stays reactive across pending, empty, failure, update, an
pending.resolve(status("plus", 75)); pending.resolve(status("plus", 75));
await setup.waitForFrame((frame) => frame.includes("ChatGPT quota (plus)")); await setup.waitForFrame((frame) => frame.includes("ChatGPT quota (plus)"));
expect(setup.captureCharFrame()).toContain("5h: 75% left · reset due"); expect(setup.captureCharFrame()).toContain("5h: 75% · ~1.5pp · due");
responses.push(async () => null); responses.push(async () => null);
notifyUpdated(); notifyUpdated();