Skip to content

Commit a148338

Browse files
author
chenbo
committed
Show desktop harness progress
1 parent 6e2e3f6 commit a148338

5 files changed

Lines changed: 102 additions & 2 deletions

File tree

apps/desktop/src/main/main.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ interface LatestReportSummary {
3131
let mainWindow: BrowserWindow | undefined;
3232
let currentTask: ChildProcess | undefined;
3333
let currentRepo: string | undefined;
34+
let currentTaskHeartbeat: NodeJS.Timeout | undefined;
35+
let currentTaskStartedAt = 0;
36+
let currentTaskLastOutputAt = 0;
3437

3538
const __filename = fileURLToPath(import.meta.url);
3639
const __dirname = path.dirname(__filename);
@@ -81,26 +84,37 @@ function registerIpc(): void {
8184
const command = commandName();
8285
const args = ["oc", "run", "--repo", repo, "--max-loops", "2", "--stream-executor", "--", task];
8386
currentRepo = repo;
87+
currentTaskStartedAt = Date.now();
88+
currentTaskLastOutputAt = currentTaskStartedAt;
8489
currentTask = spawn(command, args, {
8590
cwd: repo,
8691
env: process.env,
92+
stdio: ["ignore", "pipe", "pipe"],
8793
shell: process.platform === "win32",
8894
windowsHide: true
8995
});
9096

9197
mainWindow?.webContents.send("task:output", { stream: "system", text: `Starting: ${formatCommand(command, args)}\n` });
98+
mainWindow?.webContents.send("task:output", {
99+
stream: "system",
100+
text: `Process started with PID ${currentTask.pid ?? "unknown"}. Waiting for harness progress and OpenCode output...\n`
101+
});
102+
startTaskHeartbeat();
92103

93104
currentTask.stdout?.on("data", (chunk: Buffer) => {
105+
currentTaskLastOutputAt = Date.now();
94106
mainWindow?.webContents.send("task:output", { stream: "stdout", text: chunk.toString("utf8") });
95107
});
96108

97109
currentTask.stderr?.on("data", (chunk: Buffer) => {
110+
currentTaskLastOutputAt = Date.now();
98111
mainWindow?.webContents.send("task:output", { stream: "stderr", text: chunk.toString("utf8") });
99112
});
100113

101114
let finished = false;
102115
currentTask.once("error", (error) => {
103116
finished = true;
117+
stopTaskHeartbeat();
104118
currentTask = undefined;
105119
mainWindow?.webContents.send("task:output", { stream: "stderr", text: `${error.message}\n` });
106120
mainWindow?.webContents.send("task:exit", {
@@ -113,6 +127,7 @@ function registerIpc(): void {
113127
currentTask.once("close", (code, signal) => {
114128
if (finished) return;
115129
finished = true;
130+
stopTaskHeartbeat();
116131
const summary = currentRepo ? findLatestReportSummary(currentRepo) : {};
117132
currentTask = undefined;
118133
mainWindow?.webContents.send("task:exit", {
@@ -131,6 +146,7 @@ function registerIpc(): void {
131146
ipcMain.handle("task:stop", async () => {
132147
if (!currentTask) return { stopped: false };
133148
const pid = currentTask.pid;
149+
mainWindow?.webContents.send("task:output", { stream: "system", text: `Stopping task PID ${pid ?? "unknown"}...\n` });
134150
if (process.platform === "win32" && pid) {
135151
spawn("taskkill", ["/pid", String(pid), "/t", "/f"], { windowsHide: true });
136152
} else {
@@ -151,6 +167,30 @@ function registerIpc(): void {
151167
});
152168
}
153169

170+
function startTaskHeartbeat(): void {
171+
stopTaskHeartbeat();
172+
currentTaskHeartbeat = setInterval(() => {
173+
if (!currentTask) {
174+
stopTaskHeartbeat();
175+
return;
176+
}
177+
const now = Date.now();
178+
const silenceMs = now - currentTaskLastOutputAt;
179+
if (silenceMs < 8000) return;
180+
mainWindow?.webContents.send("task:output", {
181+
stream: "system",
182+
text: `Still running (${formatDuration(now - currentTaskStartedAt)} elapsed, no output for ${formatDuration(silenceMs)}). Waiting for harness/OpenCode output...\n`
183+
});
184+
currentTaskLastOutputAt = now;
185+
}, 8000);
186+
}
187+
188+
function stopTaskHeartbeat(): void {
189+
if (!currentTaskHeartbeat) return;
190+
clearInterval(currentTaskHeartbeat);
191+
currentTaskHeartbeat = undefined;
192+
}
193+
154194
function findLatestReport(repo: string): string | undefined {
155195
return findLatestReportSummary(repo).reportPath;
156196
}
@@ -207,6 +247,13 @@ function quoteArg(value: string): string {
207247
return /[\s"]/u.test(value) ? `"${value.replaceAll('"', '\\"').replaceAll("\n", "\\n")}"` : value;
208248
}
209249

250+
function formatDuration(milliseconds: number): string {
251+
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000));
252+
const minutes = Math.floor(totalSeconds / 60);
253+
const seconds = totalSeconds % 60;
254+
return `${minutes}:${String(seconds).padStart(2, "0")}`;
255+
}
256+
210257
registerIpc();
211258

212259
app

apps/desktop/src/renderer/src/App.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ function App() {
1515
const [status, setStatus] = useState("Select a repository and describe the coding task.");
1616
const [reportPath, setReportPath] = useState<string | undefined>();
1717
const [logs, setLogs] = useState<LogEntry[]>([]);
18+
const [runStartedAt, setRunStartedAt] = useState<number | undefined>();
19+
const [now, setNow] = useState(Date.now());
1820
const nextLogId = useRef(1);
1921
const logEndRef = useRef<HTMLSpanElement | null>(null);
2022
const bridge = window.openCodePlusPlus;
@@ -26,6 +28,7 @@ function App() {
2628
});
2729
const offExit = bridge.onTaskExit((event) => {
2830
setRunning(false);
31+
setRunStartedAt(undefined);
2932
setReportPath(event.reportPath);
3033
if (event.error) {
3134
appendLog("stderr", `${event.error}\n`);
@@ -58,6 +61,12 @@ function App() {
5861
logEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
5962
}, [logs]);
6063

64+
useEffect(() => {
65+
if (!running) return;
66+
const timer = window.setInterval(() => setNow(Date.now()), 1000);
67+
return () => window.clearInterval(timer);
68+
}, [running]);
69+
6170
const commandPreview = useMemo(() => {
6271
const normalizedTask = normalizeTaskForCli(task);
6372
if (!repo || !normalizedTask) return 'opencode-plusplus.cmd oc run --repo "<repo>" --max-loops 2 --stream-executor -- "<task>"';
@@ -97,12 +106,15 @@ function App() {
97106
setLogs([]);
98107
setReportPath(undefined);
99108
setRunning(true);
109+
setRunStartedAt(Date.now());
110+
setNow(Date.now());
100111
setStatus("Starting task...");
101112
appendLog("system", "Starting task...\n");
102113
try {
103114
const result = await bridge.runTask({ repo, task });
104115
if (result.error) {
105116
setRunning(false);
117+
setRunStartedAt(undefined);
106118
appendLog("stderr", `${result.error}\n`);
107119
setStatus(result.error);
108120
return;
@@ -111,6 +123,7 @@ function App() {
111123
appendLog("system", `$ ${result.command} ${(result.args ?? []).map(quoteArg).join(" ")}\n\n`);
112124
} catch (error) {
113125
setRunning(false);
126+
setRunStartedAt(undefined);
114127
const message = error instanceof Error ? error.message : String(error);
115128
appendLog("stderr", `${message}\n`);
116129
setStatus(`Task failed to start: ${message}`);
@@ -142,7 +155,7 @@ function App() {
142155
<p className="eyebrow">OpenCode++ Desktop MVP</p>
143156
<h1>Harness-led tasks without embedding the OpenCode TUI</h1>
144157
</div>
145-
<div className={`status ${running ? "running" : ""}`}>{running ? "Running" : "Idle"}</div>
158+
<div className={`status ${running ? "running" : ""}`}>{running ? `Running ${formatElapsed(now - (runStartedAt ?? now))}` : "Idle"}</div>
146159
</section>
147160

148161
<section className="controls">
@@ -221,4 +234,11 @@ function normalizeTaskForCli(task: string): string {
221234
return task.trim().replace(/\s+/gu, " ").replaceAll('"', "'");
222235
}
223236

237+
function formatElapsed(milliseconds: number): string {
238+
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000));
239+
const minutes = Math.floor(totalSeconds / 60);
240+
const seconds = totalSeconds % 60;
241+
return `${minutes}:${String(seconds).padStart(2, "0")}`;
242+
}
243+
224244
createRoot(document.getElementById("root") as HTMLElement).render(<App />);

docs/desktop.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ OpenCode++ remains the harness. The desktop app only gives users a visual contro
2626
- Enter a task in a desktop form.
2727
- Run `opencode-plusplus.cmd oc run --repo "<repo>" --max-loops 2 --stream-executor -- "<task>"`.
2828
- Stream stdout and stderr in real time.
29+
- Show a running heartbeat when the harness or OpenCode is still alive but has not emitted output yet.
2930
- Stop the current task.
3031
- Open the latest `.agent-context/orchestrator/<task-id>/orchestrator.md` report.
3132

src/cli/commands/opencode.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,12 @@ async function runOpencodePreset(args: string[], options: OpencodeRunCliOptions)
194194
const target = event.stream === "stderr" ? process.stderr : process.stdout;
195195
target.write(event.text);
196196
}
197+
: undefined,
198+
onProgress:
199+
options.streamExecutor && !options.json
200+
? (event) => {
201+
process.stdout.write(`[OpenCode++ ${event.phase}${event.loop ? `:${event.loop}` : ""}] ${event.message}\n`);
202+
}
197203
: undefined
198204
});
199205
console.log(

src/harness/control-plane/orchestrator.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ export interface HarnessOrchestratorOptions {
4949
checkpoint?: OrchestratorCheckpointMode;
5050
opencodeTranscript?: string;
5151
onExecutorOutput?: (event: { stream: "stdout" | "stderr"; text: string }) => void;
52+
onProgress?: (event: HarnessProgressEvent) => void;
53+
}
54+
55+
export interface HarnessProgressEvent {
56+
at: string;
57+
phase: "context" | "plan" | "sandbox" | "execute" | "collect" | "evaluate" | "decision" | "write";
58+
message: string;
59+
loop?: number;
5260
}
5361

5462
export interface AgentExecutorInput {
@@ -65,6 +73,7 @@ export interface AgentExecutorInput {
6573
executorCommand?: string;
6674
dryRun?: boolean;
6775
onExecutorOutput?: (event: { stream: "stdout" | "stderr"; text: string }) => void;
76+
onProgress?: (event: HarnessProgressEvent) => void;
6877
}
6978

7079
export interface AgentExecutorResult {
@@ -171,21 +180,29 @@ export async function runHarnessOrchestrator(repo: string, task: string, options
171180
const executorName = options.executor ?? "mock";
172181
const maxLoops = Math.max(1, options.maxLoops ?? 1);
173182
const root = path.resolve(repo);
183+
const progress = (phase: HarnessProgressEvent["phase"], message: string, loop?: number) => {
184+
options.onProgress?.({ at: new Date().toISOString(), phase, message, loop });
185+
};
174186

187+
progress("context", `building repository context for ${root}`);
175188
const preContext = await buildContextPackage(root);
189+
progress("context", "writing context package");
176190
const contextWrite = writeContextPackage(preContext);
177191
const executor = createAgentExecutor(executorName);
192+
progress("plan", "writing task run and edit boundary");
178193
const taskRun = writeTaskRun(preContext, task, { base, type: options.type ?? "auto", tokenBudget: options.tokenBudget, preserveTrace: true });
179194
const dir = path.join(root, ".agent-context", "orchestrator", taskRun.runId);
180195
mkdirSync(dir, { recursive: true });
181196
const checkpoint = createCheckpoint(root, taskRun.runId, taskRun.dir, options.checkpoint ?? "none");
182197
const sandbox = createSandboxAdapter(options.checkpoint ?? "none");
198+
progress("sandbox", `preparing ${options.checkpoint ?? "none"} sandbox`);
183199
const sandboxHandle = await sandbox.prepare(taskRun.runId, root);
184200
let sandboxDiscarded = sandboxHandle.mode === "host";
185201
const iterations: OrchestratorIterationReport[] = [];
186202
let previousDecision: HarnessOrchestratorReport["decision"] | undefined;
187203
let latestContext = preContext;
188204
if (sandboxHandle.mode === "git-worktree") {
205+
progress("context", "building context inside git-worktree sandbox");
189206
latestContext = await buildContextPackage(sandboxHandle.root);
190207
writeContextPackage(latestContext);
191208
writeTaskRun(latestContext, task, { base, type: options.type ?? "auto", tokenBudget: options.tokenBudget, preserveTrace: true });
@@ -200,7 +217,9 @@ export async function runHarnessOrchestrator(repo: string, task: string, options
200217

201218
try {
202219
for (let loopIndex = 1; loopIndex <= maxLoops; loopIndex += 1) {
220+
progress("plan", `starting loop ${loopIndex} of ${maxLoops}`, loopIndex);
203221
if (loopIndex > 1 || previousDecision?.action === "repack") {
222+
progress("context", "refreshing context for next loop", loopIndex);
204223
latestContext = await buildContextPackage(sandboxHandle.root);
205224
writeContextPackage(latestContext);
206225
writeTaskRun(latestContext, task, { base, type: options.type ?? "auto", tokenBudget: options.tokenBudget, preserveTrace: true });
@@ -211,6 +230,7 @@ export async function runHarnessOrchestrator(repo: string, task: string, options
211230
mkdirSync(iterationDir, { recursive: true });
212231
const prompt = buildExecutorPrompt(latestContext, taskRun, executorName, options, previousDecision, loopIndex);
213232
const promptFile = write(path.join(iterationDir, "prompt.md"), prompt);
233+
progress("execute", `launching ${executorName} executor`, loopIndex);
214234
const executorResult = await executor({
215235
repo: sandboxHandle.root,
216236
hostRepo: root,
@@ -224,9 +244,12 @@ export async function runHarnessOrchestrator(repo: string, task: string, options
224244
agent: options.agent,
225245
executorCommand: options.executorCommand,
226246
dryRun: options.dryRun,
227-
onExecutorOutput: options.onExecutorOutput
247+
onExecutorOutput: options.onExecutorOutput,
248+
onProgress: options.onProgress
228249
});
250+
progress("collect", `${executorName} executor finished with exit code ${executorResult.exitCode ?? "unknown"}`, loopIndex);
229251

252+
progress("collect", "normalizing executor events", loopIndex);
230253
const normalized = normalizeAgentEvents({
231254
executor: executorName,
232255
stdout: executorResult.stdout,
@@ -246,6 +269,7 @@ export async function runHarnessOrchestrator(repo: string, task: string, options
246269
appendExecutorTrace(root, taskRun.runId, executorName, executorResult, loopIndex, normalized.warnings);
247270
mirrorTraceForEvaluation(root, sandboxHandle.root, taskRun.runId);
248271

272+
progress("evaluate", "running hallucination, regression, impact, policy, and verify checks", loopIndex);
249273
const postContext = await buildContextPackage(sandboxHandle.root);
250274
const hallucination = buildHallucinationReport(postContext, { base, traceId: taskRun.runId, task });
251275
writeHallucinationReport(postContext, hallucination);
@@ -298,6 +322,7 @@ export async function runHarnessOrchestrator(repo: string, task: string, options
298322
} else {
299323
latestDecision = decision;
300324
}
325+
progress("decision", `decision: ${latestDecision.action}`, loopIndex);
301326

302327
const iterationFiles = writeIterationArtifacts(root, iterationDir, {
303328
runId: taskRun.runId,
@@ -438,6 +463,7 @@ export async function runHarnessOrchestrator(repo: string, task: string, options
438463
];
439464
report.artifacts.orchestratorFiles = files.map((file) => path.relative(root, file).replaceAll("\\", "/"));
440465
writeFileSync(path.join(dir, "orchestrator.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8");
466+
progress("write", `wrote orchestrator report to ${path.relative(root, path.join(dir, "orchestrator.md")).replaceAll("\\", "/")}`);
441467

442468
return { report, files };
443469
} finally {

0 commit comments

Comments
 (0)