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
26 changes: 21 additions & 5 deletions electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,16 +144,32 @@ function focusMainWindow() {
}

/**
* Desktop notification when a run settles while the window is in the
* background. Click focuses the window and selects that thread.
* Notification state of a thread: its status, or "waiting" when the run is
* blocked on a permission prompt (same guard the sidebar badge uses).
* @param {{ status?: string, awaitingInput?: boolean }} thread
*/
function threadNotifyState(thread) {
return thread.status === "working" && thread.awaitingInput
? "waiting"
: thread.status;
}

/**
* Desktop notification when a run settles or blocks on a prompt while the
* window is in the background. Click focuses the window and selects that
* thread.
* @param {{ id: string, title?: string, status: string }} thread
*/
function notifyThreadComplete(thread) {
if (typeof Notification !== "function") return;
if (Notification.isSupported && !Notification.isSupported()) return;
const n = new Notification({
title: thread.title || "Thread",
body: thread.status === "failed" ? "failed" : "done",
body: threadNotifyState(thread) === "waiting"
? "needs permission"
: thread.status === "failed"
? "failed"
: "done",
});
n.on("click", () => {
const win = focusMainWindow();
Expand Down Expand Up @@ -242,7 +258,7 @@ app.whenReady().then(async () => {

const lastStatus = new Map();
for (const t of store.getThreads()) {
lastStatus.set(t.id, t.status);
lastStatus.set(t.id, threadNotifyState(t));
}

runner = createRunner({
Expand All @@ -251,7 +267,7 @@ app.whenReady().then(async () => {
pushFn: (channel, payload) => {
if (channel === "thread:updated" && payload && payload.thread) {
const prev = lastStatus.get(payload.thread.id);
const next = payload.thread.status;
const next = threadNotifyState(payload.thread);
if (shouldNotify(prev, next, isAnyWindowFocused())) {
notifyThreadComplete(payload.thread);
}
Expand Down
14 changes: 11 additions & 3 deletions electron/notify.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
"use strict";

/**
* Whether a thread status change should post a desktop notification.
* Whether a thread state change should post a desktop notification.
* Never notify while the app window is focused.
*
* States are thread.status plus the synthetic "waiting" (working, but blocked
* on a permission prompt): a run that stalls on a prompt is as much a "come
* back to me" moment as one that finished, and unattended orchestration
* workers stall there invisibly otherwise (issue #31).
*
* @param {string | undefined | null} prevStatus
* @param {string | undefined | null} nextStatus
* @param {boolean} windowFocused
* @returns {boolean}
*/
function shouldNotify(prevStatus, nextStatus, windowFocused) {
if (windowFocused) return false;
if (prevStatus !== "working") return false;
return nextStatus === "done" || nextStatus === "failed";
if (prevStatus !== "working" && prevStatus !== "waiting") return false;
if (prevStatus === nextStatus) return false;
return (
nextStatus === "done" || nextStatus === "failed" || nextStatus === "waiting"
);
}

module.exports = { shouldNotify };
20 changes: 18 additions & 2 deletions electron/orchServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ const INSTRUCTIONS =
"its last assistant reply. Runs are asynchronous: send work, then continue. " +
"When a worker you forked finishes (done or failed) you are woken on a new turn " +
"with a notice; do not sit idle waiting for the user to relay that. " +
"thread_status still has full details if you need them before the wake-up.";
"thread_status still has full details if you need them before the wake-up. " +
"A worker can also stall on a permission prompt only the user can answer: that " +
"sends no notice and stays \"working\", so if one goes quiet check thread_status " +
"for awaitingInput and tell the user what it is waiting on.";

/**
* Can this project host a git worktree? Remote projects are excluded (same
Expand Down Expand Up @@ -279,12 +282,25 @@ function createToolHandlers(deps) {
}
}
}
// A worker blocked on a permission prompt stays "working" forever and
// never fires a wake-up notice, so the orchestrator must be able to see
// it (issue #31). Same guard as the sidebar badge: a stale flag left by
// a run that died mid-prompt must not read as blocked.
const awaitingInput =
thread.status === "working" && thread.awaitingInput === true;
let awaitingPermission = null;
if (awaitingInput && typeof runner.getPendingPermission === "function") {
const pending = runner.getPendingPermission(args.threadId);
if (pending) awaitingPermission = pending.summary || pending.toolName;
}
return {
status: thread.status ?? null,
title: thread.title ?? null,
provider: thread.provider ?? null,
lastAssistantText,
lastError,
awaitingInput,
awaitingPermission,
};
}

Expand Down Expand Up @@ -347,7 +363,7 @@ function buildMcpServer(sdk, handlers) {
"thread_status",
{
description:
"Status of one thread: status, title, provider, lastAssistantText (first line of the last assistant message, null when none), lastError (run error detail when status is failed, null otherwise).",
"Status of one thread: status, title, provider, lastAssistantText (first line of the last assistant message, null when none), lastError (run error detail when status is failed, null otherwise), awaitingInput (true when the run is stalled on a permission prompt only the user can answer) and awaitingPermission (what it is asking for).",
inputSchema: {
threadId: z.string().min(1),
},
Expand Down
52 changes: 48 additions & 4 deletions electron/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,14 @@ function createRunner(opts) {
/** @type {Map<string, object>} */
const lastWorkflowByThread = new Map();

/**
* Arrays as last pushed per thread, for the tail diff in pushDetail, plus
* the push counter the renderer uses to spot dropped pushes. Holds element
* references only (the arrays are store slices), never clones.
* @type {Map<string, { messages: object[], workLog: object[], seq: number }>}
*/
const lastPushByThread = new Map();

/** Batched session transcript recorder (POST /api/session). */
const sessionRecorder = createSessionRecorder({
userDataPath,
Expand Down Expand Up @@ -764,9 +772,26 @@ function createRunner(opts) {
return "";
}

/**
* Index of the first element that differs, by reference. The store patches
* messages/work-log items immutably ({...old, ...patch}), so an unchanged
* item keeps its identity and everything before the first difference is
* already on the renderer.
* @param {unknown[] | undefined} prev
* @param {unknown[]} next
*/
function firstChanged(prev, next) {
if (!prev) return 0;
const n = Math.min(prev.length, next.length);
let i = 0;
while (i < n && prev[i] === next[i]) i++;
return i;
}

function pushDetail(threadId, workflow) {
// Deleted threads must not resurrect via late agent/sim pushes.
if (threadId == null || !store.getThread(threadId)) {
lastPushByThread.delete(threadId);
return null;
}
if (workflow) {
Expand Down Expand Up @@ -797,7 +822,25 @@ function createRunner(opts) {
markVisited: false,
pendingPermission: getPendingPermission(threadId),
});
pushFn("thread:updated", detail);
// Stream tails, not the transcript: the largest threads are megabytes and
// this runs on every chunk. The renderer merges (src/threadPatch.ts).
const prev = lastPushByThread.get(threadId);
const messagesFrom = firstChanged(prev && prev.messages, detail.messages);
const workLogFrom = firstChanged(prev && prev.workLog, detail.workLog);
const seq = (prev ? prev.seq : 0) + 1;
lastPushByThread.set(threadId, {
messages: detail.messages,
workLog: detail.workLog,
seq,
});
pushFn("thread:updated", {
...detail,
messages: detail.messages.slice(messagesFrom),
messagesFrom,
workLog: detail.workLog.slice(workLogFrom),
workLogFrom,
seq,
});
return detail;
}

Expand Down Expand Up @@ -3506,9 +3549,10 @@ function createRunner(opts) {
}
// Drain any pending session transcript posts before process exit.
void sessionRecorder.flush();
if (marked) {
store.save();
}
// App quit (main.js before-quit): save() only arms a 250 ms unref'd timer,
// and a SIGTERM never runs the exit hook that flushes it, so the idle
// marking above would be lost. Put the bytes on disk now.
store.saveNow();
}

/**
Expand Down
13 changes: 13 additions & 0 deletions electron/test/notify.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ describe("shouldNotify", () => {
assert.equal(shouldNotify("working", "failed", true), false);
});

it("notifies working -> waiting: a run blocked on a prompt is a stall", () => {
assert.equal(shouldNotify("working", "waiting", false), true);
assert.equal(shouldNotify("working", "waiting", true), false);
// One prompt, one notification.
assert.equal(shouldNotify("waiting", "waiting", false), false);
});

it("still notifies once the answered run settles", () => {
assert.equal(shouldNotify("waiting", "working", false), false);
assert.equal(shouldNotify("waiting", "done", false), true);
assert.equal(shouldNotify("waiting", "failed", false), true);
});

it("does not notify other status transitions", () => {
assert.equal(shouldNotify("working", "working", false), false);
assert.equal(shouldNotify("working", "idle", false), false);
Expand Down
23 changes: 23 additions & 0 deletions electron/test/orch-server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,8 @@ describe("orch-server tool handlers", () => {
provider: "claude",
lastAssistantText: "first line",
lastError: null,
awaitingInput: false,
awaitingPermission: null,
});
// No assistant message: null.
assert.deepEqual(await h.thread_status({ threadId: "t2" }), {
Expand All @@ -296,6 +298,8 @@ describe("orch-server tool handlers", () => {
provider: "codex",
lastAssistantText: null,
lastError: null,
awaitingInput: false,
awaitingPermission: null,
});
await assert.rejects(
() => h.thread_status({ threadId: "ghost" }),
Expand All @@ -312,8 +316,27 @@ describe("orch-server tool handlers", () => {
provider: "grok",
lastAssistantText: "starting",
lastError: "Run error: result subtype error_during_execution",
awaitingInput: false,
awaitingPermission: null,
});
});

it("thread_status reports a worker blocked on a permission prompt", async () => {
const deps = makeDeps();
deps.runner.getPendingPermission = (id) =>
id === "t2" ? { toolName: "Bash", summary: "Bash(rm -rf build)" } : null;
deps.store.getThread("t2").awaitingInput = true;
const h = createToolHandlers(deps);
const status = await h.thread_status({ threadId: "t2" });
assert.equal(status.awaitingInput, true);
assert.equal(status.awaitingPermission, "Bash(rm -rf build)");

// Flag left behind by a run that died mid-prompt must not read as blocked.
deps.store.getThread("t3").awaitingInput = true;
const dead = await h.thread_status({ threadId: "t3" });
assert.equal(dead.awaitingInput, false);
assert.equal(dead.awaitingPermission, null);
});
});

/**
Expand Down
57 changes: 52 additions & 5 deletions electron/test/runner.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,28 @@ function orchNoticeMessages(store, threadId) {
);
}

/**
* Fold thread:updated tails into a full detail, the way the renderer does
* (src/threadPatch.ts). Pushes carry only what changed since the last one.
*/
function foldPushes(pushes, threadId) {
let messages = [];
let workLog = [];
let last = null;
for (const p of pushes) {
if (p.channel !== "thread:updated") continue;
if (!p.payload || p.payload.thread.id !== threadId) continue;
messages = messages
.slice(0, p.payload.messagesFrom || 0)
.concat(p.payload.messages);
workLog = workLog
.slice(0, p.payload.workLogFrom || 0)
.concat(p.payload.workLog);
last = p.payload;
}
return last ? { ...last, messages, workLog } : null;
}

describe("runner simulated mode", () => {
let tmpDir;
let store;
Expand Down Expand Up @@ -273,11 +295,8 @@ describe("runner simulated mode", () => {
const doneThread = store.getThreads().find((t) => t.id === thread.id);
assert.equal(doneThread.runStartedAt, null);

const lastPush = [...pushes]
.reverse()
.find((p) => p.channel === "thread:updated");
assert.ok(lastPush, "expected thread:updated push");
const detail = lastPush.payload;
const detail = foldPushes(pushes, thread.id);
assert.ok(detail, "expected thread:updated push");
assert.equal(detail.thread.status, "done");
assert.ok(detail.workflow);
assert.equal(detail.workflow.complete, true);
Expand Down Expand Up @@ -320,6 +339,34 @@ describe("runner simulated mode", () => {
assert.ok(runner.getActiveWorkflow(thread.id));
});

it("streams tails, not the whole transcript, and they fold back to it", async () => {
const thread = store.getThreads()[0];
await runner.startRun({ threadId: thread.id, prompt: "stream tails" });
await waitFor(() => {
const t = store.getThread(thread.id);
return t && t.status === "done";
});

const full = services.getThreadDetail(store, thread.id);
const folded = foldPushes(pushes, thread.id);
assert.deepEqual(folded.messages, full.messages);
assert.deepEqual(folded.workLog, full.workLog);

const streamed = pushes.filter((p) => p.channel === "thread:updated");
assert.ok(streamed.length > 2);
// Only the first push may carry everything; the rest are tails.
assert.ok(
streamed
.slice(1)
.every((p) => p.payload.messages.length < full.messages.length),
"later pushes must not re-send the whole message array",
);
assert.ok(
streamed.some((p) => p.payload.messagesFrom > 0),
"expected at least one push to skip an unchanged prefix",
);
});

it("archives a finished worker once the crew is quiet", async () => {
const { orch, worker } = orchPair(store);
await runner.startRun({ threadId: worker.id, prompt: "worker task" });
Expand Down
4 changes: 3 additions & 1 deletion electron/test/workflow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,9 @@ describe("workflow orchestration", () => {
assert.ok(!c.argv.includes("--resume"), "workflow agents must not pass --resume");
}

const detail = push.payload;
// Pushes carry only work-log tails now (see runner.pushDetail), so the
// one-item-per-phase check reads the store's full log.
const detail = services.getThreadDetail(store, thread.id);
for (const pl of ["seed", "analyze", "synthesize"]) {
const items = detail.workLog.filter((w) => w.label.toLowerCase() === pl);
assert.equal(items.length, 1, `expected one work log for ${pl}`);
Expand Down
1 change: 0 additions & 1 deletion node_modules

This file was deleted.

9 changes: 6 additions & 3 deletions src/components/Markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isValidElement, useState, type ReactNode } from "react";
import { isValidElement, memo, useState, type ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import styles from "./Markdown.module.css";
Expand Down Expand Up @@ -67,8 +67,11 @@ function CodeBlock({ children }: { children?: ReactNode }) {
* Assistant-message markdown. react-markdown renders to React elements (no
* dangerouslySetInnerHTML), so raw HTML in agent output is dropped, not
* executed.
*
* memo: parsing is the expensive part of a streamed update, and only the
* message being written has new text.
*/
export function Markdown({ text }: { text: string }) {
export const Markdown = memo(function Markdown({ text }: { text: string }) {
return (
<div className={styles.md}>
<ReactMarkdown
Expand All @@ -89,4 +92,4 @@ export function Markdown({ text }: { text: string }) {
</ReactMarkdown>
</div>
);
}
});
Loading
Loading