Skip to content

Commit 7771987

Browse files
author
chenbo
committed
Add desktop MVP shell
1 parent 81d09d7 commit 7771987

16 files changed

Lines changed: 776 additions & 27 deletions

README.en.md

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,17 @@ flowchart TD
9696
Latest --> Doctor["opencode-plusplus doctor"]
9797
```
9898

99+
## Desktop MVP
100+
101+
OpenCode++ also includes an experimental Desktop MVP in `apps/desktop`. It intentionally does not embed the OpenCode TUI. Instead, it provides a desktop control surface for selecting a repository, entering a task, running `opencode-plusplus.cmd oc run "<task>" --repo "<repo>" --max-loops 2`, streaming stdout/stderr, stopping the current task, and opening the generated orchestrator report.
102+
103+
See [Desktop MVP](docs/desktop.md) for setup and architecture details.
104+
99105
## Advanced Usage
100106

101107
The README main path intentionally recommends only `opencode-plusplus`. Batch Harness Mode, CI-like executors, manual `verify / policy / impact`, MCP, and retrieval commands are still available for advanced users:
102108

109+
- [Desktop MVP](docs/desktop.md)
103110
- [OpenCode Transparent Sidecar Mode](docs/integrations/opencode-sidecar.md)
104111
- [Executor CLI Integration](docs/integrations/executor-cli.md)
105112
- [CLI Reference](docs/reference/cli-reference.md)
@@ -125,33 +132,35 @@ The README main path intentionally recommends only `opencode-plusplus`. Batch Ha
125132

126133
## Current Maturity
127134

128-
| Capability | Current status | Notes |
129-
| ------------------------------------------------ | ---------------- | ------------------------------------------------------------------------------------------------------- |
130-
| `opencode-plusplus` OpenCode TUI launcher | MVP | Runs preflight, prints compact readiness, launches OpenCode TUI, and supports `--pure` |
131-
| OpenCode transparent sidecar plugin | MVP | Injects `.opencode/plugins/opencode-plusplus.ts` and listens for session/file/tool events |
132-
| sidecar command guard | MVP+ | Blocks dangerous commands, unknown package scripts / Makefile targets, and protected / secret paths |
133-
| sidecar post-tool evidence | Foundation | Uses `tool.execute.after` to record exit code, timestamps, output hashes, and working-tree hashes |
134-
| sidecar verify / shared guard stack | Foundation | Reuses contracts, hallucination, regression, impact, tests, and policy; needs more real-repo validation |
135-
| `opencode-plusplus report/status/doctor` | Foundation | Reads sidecar reports, checks active state, and diagnoses OpenCode / auth / git / context |
136-
| batch OpenCode executor / `opencode-plusplus oc` | Foundation | Best for benchmarks, CI-like runs, non-interactive tasks, and repeatable demos |
137-
| bounded harness-led orchestrator / `orchestrate` | Foundation | Supports multi-loop artifacts, checkpoints, executor commands, and decision reports |
138-
| `build` / `AGENTS.md` / `.agent-context` | Stable | Stable repository context compiler and generated artifacts |
139-
| task plan / pack / run | Stable | Stable task context, boundaries, prompts, and trace files |
140-
| TypeScript Compiler API analyzer | Stable | Main TypeScript / JavaScript analyzer path is stable |
141-
| Python AST / optional Tree-sitter analyzer | Foundation | Python analyzer is usable; Tree-sitter remains optional |
142-
| Hallucination Guard | MVP | Deterministic checks for missing files, commands, dependencies, config, and symbols |
143-
| Regression Guard / memory candidates | MVP / Foundation | Structured regression memory and candidate flow are implemented |
144-
| MCP stdio server + core tools | Foundation | Core MCP tools work; end-to-end client integrations still need per-client validation |
145-
| MCP Agent Native Runtime tools | Experimental | start/step/evaluate/repair/finalize remain experimental |
146-
| MiMoCode / Codex / Claude native normalizers | Planned | More real agent transcript / JSONL normalizers are planned |
147-
| RAG export / retriever provider interface | Foundation | Export and provider interfaces are implemented |
148-
| direct LightRAG server sync | Planned | Planned |
135+
| Capability | Current status | Notes |
136+
| ------------------------------------------------ | ---------------- | -------------------------------------------------------------------------------------------------------- |
137+
| `opencode-plusplus` OpenCode TUI launcher | MVP | Runs preflight, prints compact readiness, launches OpenCode TUI, and supports `--pure` |
138+
| OpenCode transparent sidecar plugin | MVP | Injects `.opencode/plugins/opencode-plusplus.ts` and listens for session/file/tool events |
139+
| sidecar command guard | MVP+ | Blocks dangerous commands, unknown package scripts / Makefile targets, and protected / secret paths |
140+
| sidecar post-tool evidence | Foundation | Uses `tool.execute.after` to record exit code, timestamps, output hashes, and working-tree hashes |
141+
| sidecar verify / shared guard stack | Foundation | Reuses contracts, hallucination, regression, impact, tests, and policy; needs more real-repo validation |
142+
| `opencode-plusplus report/status/doctor` | Foundation | Reads sidecar reports, checks active state, and diagnoses OpenCode / auth / git / context |
143+
| Electron Desktop MVP | MVP | Selects a repo, runs harness-led tasks through the CLI, streams stdout/stderr, stops tasks, opens report |
144+
| batch OpenCode executor / `opencode-plusplus oc` | Foundation | Best for benchmarks, CI-like runs, non-interactive tasks, and repeatable demos |
145+
| bounded harness-led orchestrator / `orchestrate` | Foundation | Supports multi-loop artifacts, checkpoints, executor commands, and decision reports |
146+
| `build` / `AGENTS.md` / `.agent-context` | Stable | Stable repository context compiler and generated artifacts |
147+
| task plan / pack / run | Stable | Stable task context, boundaries, prompts, and trace files |
148+
| TypeScript Compiler API analyzer | Stable | Main TypeScript / JavaScript analyzer path is stable |
149+
| Python AST / optional Tree-sitter analyzer | Foundation | Python analyzer is usable; Tree-sitter remains optional |
150+
| Hallucination Guard | MVP | Deterministic checks for missing files, commands, dependencies, config, and symbols |
151+
| Regression Guard / memory candidates | MVP / Foundation | Structured regression memory and candidate flow are implemented |
152+
| MCP stdio server + core tools | Foundation | Core MCP tools work; end-to-end client integrations still need per-client validation |
153+
| MCP Agent Native Runtime tools | Experimental | start/step/evaluate/repair/finalize remain experimental |
154+
| MiMoCode / Codex / Claude native normalizers | Planned | More real agent transcript / JSONL normalizers are planned |
155+
| RAG export / retriever provider interface | Foundation | Export and provider interfaces are implemented |
156+
| direct LightRAG server sync | Planned | Planned |
149157

150158
See the [documentation home](docs/README.md) for full maturity notes.
151159

152160
## Documentation
153161

154162
- [Getting Started](docs/getting-started.md)
163+
- [Desktop MVP](docs/desktop.md)
155164
- [Positioning](docs/concepts/positioning.md)
156165
- [Architecture](docs/concepts/architecture.md)
157166
- [Guard Modules](docs/concepts/guard-modules.md)

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,18 @@ opencode-plusplus --pure # 纯 OpenCode,不启用 OpenCode++
7070

7171
`opencode-plusplus` 会执行 preflight,确保 `.agent-context`,写入 `.opencode/plugins/opencode-plusplus.ts`,准备 OpenCode commands/agent 文件,先打印简短状态,再进入当前仓库的 OpenCode TUI。sidecar plugin 会监听 `tool.execute.before``tool.execute.after``file.edited``session.idle`:执行危险命令、幻觉 package script / Makefile target、触碰 protected / secret path 时会前置阻断;工具执行结束后会记录 command、exit code、stdout/stderr hash、working tree hash 和 touched files;OpenCode 空闲且有 dirty diff 时会自动运行增量验证,写入 `.agent-context/sidecar/latest.json``.agent-context/sidecar/latest.md`
7272

73+
## Desktop MVP
74+
75+
OpenCode++ 现在新增了实验性的 Desktop MVP,代码位于 `apps/desktop`。它不嵌入 OpenCode TUI,而是提供一个桌面控制台:选择 repo、输入任务、调用 `opencode-plusplus.cmd oc run "<task>" --repo "<repo>" --max-loops 2`、实时显示 stdout/stderr、停止当前任务,并打开生成的 report。
76+
77+
详见 [Desktop MVP](docs/desktop.md)
78+
7379
## 高级用法
7480

7581
首页主路径只推荐 `opencode-plusplus`。批处理 Harness Mode、CI-like executor、手动 `verify / policy / impact`、MCP 和 retrieval 等内核能力保留给高级用户:
7682

7783
- [OpenCode Transparent Sidecar Mode](docs/integrations/opencode-sidecar.md)
84+
- [Desktop MVP](docs/desktop.md)
7885
- [Executor CLI Integration](docs/integrations/executor-cli.md)
7986
- [CLI Reference](docs/reference/cli-reference.md)
8087
- [MCP Tools](docs/reference/mcp-tools.md)

apps/desktop/package.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "@opencode-plusplus/desktop",
3+
"version": "0.1.0",
4+
"private": true,
5+
"type": "module",
6+
"main": "dist/main/main.js",
7+
"scripts": {
8+
"build": "npm run build:main && npm run build:renderer",
9+
"build:main": "tsc -p tsconfig.main.json",
10+
"build:renderer": "vite build",
11+
"check": "tsc -p tsconfig.main.json --noEmit && tsc -p tsconfig.renderer.json --noEmit",
12+
"lint": "eslint \"src/**/*.{ts,tsx}\"",
13+
"start": "electron dist/main/main.js"
14+
},
15+
"dependencies": {
16+
"react": "^19.2.3",
17+
"react-dom": "^19.2.3"
18+
},
19+
"devDependencies": {
20+
"@vitejs/plugin-react": "^5.2.0",
21+
"@types/react": "^19.2.7",
22+
"@types/react-dom": "^19.2.3",
23+
"electron": "^39.2.6",
24+
"vite": "^6.4.3"
25+
}
26+
}

apps/desktop/src/main/main.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2+
import { existsSync, readdirSync, statSync } from "node:fs";
3+
import path from "node:path";
4+
import { fileURLToPath } from "node:url";
5+
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
6+
7+
interface RunTaskInput {
8+
repo: string;
9+
task: string;
10+
}
11+
12+
interface RunTaskStarted {
13+
pid: number | null;
14+
command: string;
15+
args: string[];
16+
}
17+
18+
interface RunTaskRejected {
19+
error: string;
20+
}
21+
22+
type RunTaskResult = RunTaskStarted | RunTaskRejected;
23+
24+
let mainWindow: BrowserWindow | undefined;
25+
let currentTask: ChildProcessWithoutNullStreams | undefined;
26+
let currentRepo: string | undefined;
27+
28+
const __filename = fileURLToPath(import.meta.url);
29+
const __dirname = path.dirname(__filename);
30+
31+
async function createWindow(): Promise<void> {
32+
mainWindow = new BrowserWindow({
33+
width: 1180,
34+
height: 780,
35+
minWidth: 960,
36+
minHeight: 620,
37+
title: "OpenCode++ Desktop",
38+
webPreferences: {
39+
preload: path.join(__dirname, "preload.js"),
40+
contextIsolation: true,
41+
nodeIntegration: false
42+
}
43+
});
44+
45+
const devServer = process.env.OPENCODE_PLUSPLUS_DESKTOP_RENDERER_URL;
46+
if (devServer) {
47+
await mainWindow.loadURL(devServer);
48+
} else {
49+
await mainWindow.loadFile(path.join(__dirname, "..", "renderer", "index.html"));
50+
}
51+
}
52+
53+
function commandName(): string {
54+
return process.platform === "win32" ? "opencode-plusplus.cmd" : "opencode-plusplus";
55+
}
56+
57+
function registerIpc(): void {
58+
ipcMain.handle("repo:select", async () => {
59+
const result = await dialog.showOpenDialog({
60+
properties: ["openDirectory"],
61+
title: "Select repository"
62+
});
63+
return result.canceled ? undefined : result.filePaths[0];
64+
});
65+
66+
ipcMain.handle("task:run", (_event, input: RunTaskInput): RunTaskResult => {
67+
if (currentTask) return { error: "A task is already running." };
68+
const repo = input.repo.trim();
69+
const task = input.task.trim();
70+
if (!repo) return { error: "Select a repository first." };
71+
if (!task) return { error: "Enter a task first." };
72+
if (!existsSync(repo)) return { error: `Repository does not exist: ${repo}` };
73+
74+
const command = commandName();
75+
const args = ["oc", "run", task, "--repo", repo, "--max-loops", "2"];
76+
currentRepo = repo;
77+
currentTask = spawn(command, args, {
78+
cwd: repo,
79+
shell: false,
80+
windowsHide: true
81+
});
82+
83+
currentTask.stdout.on("data", (chunk: Buffer) => {
84+
mainWindow?.webContents.send("task:output", { stream: "stdout", text: chunk.toString("utf8") });
85+
});
86+
87+
currentTask.stderr.on("data", (chunk: Buffer) => {
88+
mainWindow?.webContents.send("task:output", { stream: "stderr", text: chunk.toString("utf8") });
89+
});
90+
91+
currentTask.once("error", (error) => {
92+
mainWindow?.webContents.send("task:output", { stream: "stderr", text: `${error.message}\n` });
93+
});
94+
95+
currentTask.once("close", (code, signal) => {
96+
const reportPath = currentRepo ? findLatestReport(currentRepo) : undefined;
97+
currentTask = undefined;
98+
mainWindow?.webContents.send("task:exit", {
99+
code,
100+
signal,
101+
reportPath
102+
});
103+
});
104+
105+
return { pid: currentTask.pid ?? null, command, args };
106+
});
107+
108+
ipcMain.handle("task:stop", async () => {
109+
if (!currentTask) return { stopped: false };
110+
const pid = currentTask.pid;
111+
if (process.platform === "win32" && pid) {
112+
spawn("taskkill", ["/pid", String(pid), "/t", "/f"], { windowsHide: true });
113+
} else {
114+
currentTask.kill("SIGTERM");
115+
}
116+
return { stopped: true };
117+
});
118+
119+
ipcMain.handle("report:latest", (_event, repo: string) => {
120+
return findLatestReport(repo);
121+
});
122+
123+
ipcMain.handle("report:open", async (_event, repo: string) => {
124+
const reportPath = findLatestReport(repo);
125+
if (!reportPath) return { opened: false, error: "No orchestrator report found yet." };
126+
const error = await shell.openPath(reportPath);
127+
return error ? { opened: false, error } : { opened: true, path: reportPath };
128+
});
129+
}
130+
131+
function findLatestReport(repo: string): string | undefined {
132+
const orchestratorDir = path.join(repo, ".agent-context", "orchestrator");
133+
if (!existsSync(orchestratorDir)) return undefined;
134+
const candidates = readdirSync(orchestratorDir, { withFileTypes: true })
135+
.filter((entry) => entry.isDirectory())
136+
.map((entry) => path.join(orchestratorDir, entry.name, "orchestrator.md"))
137+
.filter((file) => existsSync(file))
138+
.map((file) => ({ file, mtimeMs: statSync(file).mtimeMs }))
139+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
140+
return candidates[0]?.file;
141+
}
142+
143+
registerIpc();
144+
145+
app
146+
.whenReady()
147+
.then(createWindow)
148+
.catch((error: unknown) => {
149+
console.error(error);
150+
app.quit();
151+
});
152+
153+
app.on("window-all-closed", () => {
154+
if (process.platform !== "darwin") app.quit();
155+
});
156+
157+
app.on("activate", () => {
158+
if (!BrowserWindow.getAllWindows().length) {
159+
createWindow().catch((error: unknown) => console.error(error));
160+
}
161+
});

apps/desktop/src/main/preload.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { contextBridge, ipcRenderer, type IpcRendererEvent } from "electron";
2+
3+
type TaskOutputHandler = (event: { stream: "stdout" | "stderr"; text: string }) => void;
4+
type TaskExitHandler = (event: { code: number | null; signal: string | null; reportPath?: string }) => void;
5+
6+
contextBridge.exposeInMainWorld("openCodePlusPlus", {
7+
selectRepo: () => ipcRenderer.invoke("repo:select") as Promise<string | undefined>,
8+
runTask: (input: { repo: string; task: string }) =>
9+
ipcRenderer.invoke("task:run", input) as Promise<{ pid?: number | null; command?: string; args?: string[]; error?: string }>,
10+
stopTask: () => ipcRenderer.invoke("task:stop") as Promise<{ stopped: boolean }>,
11+
getLatestReport: (repo: string) => ipcRenderer.invoke("report:latest", repo) as Promise<string | undefined>,
12+
openLatestReport: (repo: string) => ipcRenderer.invoke("report:open", repo) as Promise<{ opened: boolean; path?: string; error?: string }>,
13+
onTaskOutput: (handler: TaskOutputHandler) => {
14+
const listener = (_event: IpcRendererEvent, payload: { stream: "stdout" | "stderr"; text: string }) => handler(payload);
15+
ipcRenderer.on("task:output", listener);
16+
return () => ipcRenderer.off("task:output", listener);
17+
},
18+
onTaskExit: (handler: TaskExitHandler) => {
19+
const listener = (_event: IpcRendererEvent, payload: { code: number | null; signal: string | null; reportPath?: string }) => handler(payload);
20+
ipcRenderer.on("task:exit", listener);
21+
return () => ipcRenderer.off("task:exit", listener);
22+
}
23+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export {};
2+
3+
declare global {
4+
interface Window {
5+
openCodePlusPlus: {
6+
selectRepo: () => Promise<string | undefined>;
7+
runTask: (input: { repo: string; task: string }) => Promise<{ pid?: number | null; command?: string; args?: string[]; error?: string }>;
8+
stopTask: () => Promise<{ stopped: boolean }>;
9+
getLatestReport: (repo: string) => Promise<string | undefined>;
10+
openLatestReport: (repo: string) => Promise<{ opened: boolean; path?: string; error?: string }>;
11+
onTaskOutput: (handler: (event: { stream: "stdout" | "stderr"; text: string }) => void) => () => void;
12+
onTaskExit: (handler: (event: { code: number | null; signal: string | null; reportPath?: string }) => void) => () => void;
13+
};
14+
}
15+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>OpenCode++ Desktop</title>
7+
</head>
8+
<body>
9+
<div id="root"></div>
10+
<script type="module" src="/src/App.tsx"></script>
11+
</body>
12+
</html>

0 commit comments

Comments
 (0)