Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ That makes the session key **derived, not secret**: anyone who learns the artifa
A `status: "ended"` response carries `ended_by`; the final `status: "feedback"` batch delivered right before a session ends carries the same signal via `session_ended: true` plus `ended_by`, so that last `next_step` also skips the reopen instruction.
Default no-timeout polls stream whitespace heartbeat bytes before the final JSON response and always write the one-shot wait banner to stderr - it is the "not hung" signal an agent needs while stdout stays empty - but write the recurring per-minute wait ticks only when stderr is an interactive terminal (`shouldNarratePollWaitTicks`), so agent harnesses with piped, non-TTY stdio get no unbounded tick noise in their merged capture; stdout is always reserved for the final JSON/TOON response, and `--timeout-ms` is a non-streaming test/debug escape hatch.
If SIGINT or SIGTERM interrupts a no-timeout poll, the CLI writes re-run guidance to stderr and exits with the conventional signal code; queued feedback persists, so re-running the same poll is safe.
8. The `/events/:key` SSE stream emits `agent-presence` states: `waiting` before any poll has attached, `listening` while one is active, and `working` after a poll has delivered feedback and released; the chrome allows queued feedback while waiting or listening and blocks sends only while working. An agent reply (`POST /api/:key/agent-reply`, the CLI's `--agent-reply`) concludes the working state and returns presence to `waiting`, so sends re-enable as soon as the agent answers instead of staying blocked until another poll attaches.
8. The `/events/:key` SSE stream emits `agent-presence` states: `waiting` before any poll has attached, `listening` while one is active, and `working` after a poll has delivered feedback and released; the chrome keeps all feedback actions available because the server queues them for the next poll. An agent reply (`POST /api/:key/agent-reply`, the CLI's `--agent-reply`) concludes the working state and returns presence to `waiting`, and a batch delivered with `session_ended` releases it immediately because no later poll or reply can.
Every poll exit path has to undo the presence it set: `/api/poll` subscribes to the request's `close` before its first `await` and re-checks it after arming the listeners, so a client that disconnects while the immediate-feedback take is in flight never strands `listening`.
`--agent-reply` posts a chat message into the session before polling, rendered in the browser conversation panel via the same stream.

### Passive layout-warning inbox
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ pnpm link
- **Keyboard shortcuts** - In the chrome composer, Enter sends queued prompts and Shift+Enter inserts a newline.
In the annotation card, Enter queues the annotation, Shift+Enter inserts a newline, and Ctrl+Enter (Cmd+Enter on macOS) queues it and sends all queued prompts immediately.
Cmd+I or Ctrl+I toggles between annotate and explore mode from either the browser chrome or the artifact iframe, including while focus is in a textarea or control.
- **Agent presence** - The browser shows when no agent is listening, keeps queued feedback for the next successful `lavish-axi poll` send even across reloads, and only blocks human sends while the agent is working on delivered feedback; the agent's reply (`--agent-reply`) concludes that work and re-enables sends.
- **Agent presence** - The browser shows when no agent is listening, keeps queued feedback for the next successful `lavish-axi poll` send even across reloads, and keeps human feedback actions available while the agent is working because the server queues them for the next poll. The agent's reply (`--agent-reply`) concludes delivered work and returns presence to waiting.
The no-timeout poll always writes an immediate stderr banner so it is visibly not hung; it adds the periodic stderr wait ticks only in an interactive terminal, so when stderr is piped (as under agent harnesses) the captured output carries no tick noise. Stdout always stays reserved for the final response; if the poll is interrupted or times out, re-run it because queued feedback is never lost.
Codex-specific guidance keeps that poll attached to the active turn instead of hiding it in a background task, because completed background tasks may not resume the agent.
- **Session end etiquette** - Lavish tracks who ended a session: a human clicking **End session** (or **Send & end session**) in the browser is a user-initiated end, while `lavish-axi end <html-file>` is agent-initiated.
Expand Down Expand Up @@ -262,7 +262,7 @@ For flows, architecture, state, or sequence diagrams, open the diagram playbook
| `lavish-axi export` | `--out <path>` | Write the export to a specific path instead of `<name>.export.html` next to the source. |
| `lavish-axi share` | `--password <pw>` | Make the third-party ht-ml.app page private; viewers must supply the password. |
| `lavish-axi share` | `--token <t>` | Attach an optional bearer token (`LAVISH_AXI_HTML_APP_TOKEN`); never required to publish. |
| `lavish-axi poll` | `--agent-reply "..."` | Show the agent's reply in the existing browser chat and re-enable human sends before polling again. |
| `lavish-axi poll` | `--agent-reply "..."` | Show the agent's reply in the existing browser chat, conclude delivered work, and return presence to waiting before polling again. |
| `lavish-axi poll` | `--timeout-ms <ms>` | Test/debug escape hatch only; agents should normally omit it and leave the long poll running. |
| `lavish-axi stop` | `--port <port>` | Shut down a server running on a non-default port. |
| `lavish-axi server` | `--verbose` | Log session and watcher events to stderr; can also be enabled with `LAVISH_AXI_DEBUG=1`. Detached server output is appended to `~/.lavish-axi/server.log` (or `LAVISH_AXI_STATE_DIR/server.log`) for startup and crash diagnostics. |
Expand Down
8 changes: 4 additions & 4 deletions src/chrome-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ function render() {
}

function updateSendState() {
sendButton.disabled = ended || agentPresence === "working";
sendButton.disabled = ended;
sendAndEndButton.disabled = sendButton.disabled;
if (warningsQueueButton) updateWarningSelectionState();
}
Expand Down Expand Up @@ -554,7 +554,7 @@ function requestSnapshot(action) {
}

function sendQueued(endAfter) {
if (ended || agentPresence === "working") return;
if (ended) return;
closeMenus();

const text = chatInput.value.trim();
Expand Down Expand Up @@ -982,7 +982,7 @@ function updateWarningSelectionState() {
warningsSelectAll.checked = selectable.length > 0 && selectedCount === selectable.length;
warningsSelectAll.indeterminate = selectedCount > 0 && selectedCount < selectable.length;
warningsSelected.textContent = selectedCount === 0 ? "None selected" : selectedCount + " selected";
warningsQueueButton.disabled = selectedCount === 0 || ended || agentPresence === "working";
warningsQueueButton.disabled = selectedCount === 0 || ended;
}

function toggleSelectAllWarnings() {
Expand Down Expand Up @@ -1039,7 +1039,7 @@ async function dismissWarning(id) {
// One queued batch = one ordinary queued prompt. The CLI cannot tell it apart from any other
// feedback, which is exactly the point: no parallel agent protocol.
async function queueSelectedWarningFixes() {
if (ended || agentPresence === "working") return;
if (ended) return;
const ids = [...selectedWarningIds];
if (ids.length === 0) return;
warningsQueueButton.disabled = true;
Expand Down
51 changes: 44 additions & 7 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,17 @@ export async function serve({
const logEvent = verbose ? (line) => writeLog(`[lavish] ${line}`) : null;
let publicPort = port;

function finishFeedbackDelivery(key, result) {
if (result.status !== "feedback") return;
const chat = result.chat;
delete result.chat;
markFeedbackDelivered(key, activePolls, deliveredFeedback, events);
// A batch flagged `session_ended` is the last one this session will ever deliver, so no
// later poll or agent reply can retire the working state markFeedbackDelivered just set:
// release it here or presence reports an agent still working on a session that is over.
if (result.session_ended) clearFeedbackDelivery(key, activePolls, deliveredFeedback, events);
if (Array.isArray(chat)) events.emit("chat-sync", key, chat);
}
// Whiteboard sidecar files live next to state.json, keyed by session + diagram.
const whiteboardStateRoot = path.dirname(stateFile);

Expand Down Expand Up @@ -376,17 +387,34 @@ export async function serve({
});

app.get("/api/poll", async (req, res, next) => {
// `close` is subscribed before the first `await` and re-checked after the listeners are armed,
// because a client that disconnects while `takeFeedback` is in flight would otherwise arrive
// too late for its own cleanup: the handler marks the poll active afterwards and nothing left
// would clear it, leaving presence stuck on "listening" for an agent that is already gone.
let requestClosed = Boolean(req.destroyed);
let cleanupPoll = null;
const onRequestClose = () => {
requestClosed = true;
cleanupPoll?.();
};
const detachRequestClose = () => req.off("close", onRequestClose);
req.on("close", onRequestClose);
try {
const file = await canonicalFile(String(req.query.file || ""));
const key = sessionKey(file);
const timeoutMs =
req.query.timeoutMs === undefined ? null : Math.max(0, Math.min(Number(req.query.timeoutMs || 0), 2147483647));
const immediate = await store.takeFeedback(key);
if (immediate.status !== "waiting") {
if (immediate.status === "feedback") markFeedbackDelivered(key, activePolls, deliveredFeedback, events);
finishFeedbackDelivery(key, immediate);
detachRequestClose();
Comment thread
greptile-apps[bot] marked this conversation as resolved.
res.json(immediate);
Comment on lines +449 to 450

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Response completion escapes restoration

When the polling client disconnects after the closure check but before the immediate JSON response finishes, this branch marks the destructively taken batch as delivered and detaches the close listener before res.json completes, causing the feedback to be lost and presence to remain working without an agent receiving it.

return;
}
if (requestClosed || req.destroyed || res.writableEnded) {
detachRequestClose();
return;
}
const streamHeartbeat = timeoutMs === null;
let heartbeat = null;
if (streamHeartbeat) {
Expand All @@ -399,7 +427,7 @@ export async function serve({
}
setPollActive(key, activePolls, deliveredFeedback, events, true);
refreshIdleTimer();
const timer = timeoutMs === null ? null : setTimeout(() => respond().catch(handleRespondError), timeoutMs);
let timer = null;
let cleaned = false;
let responding = false;
const cleanup = () => {
Expand All @@ -411,13 +439,15 @@ export async function serve({
events.off("ended", onFeedback);
setPollActive(key, activePolls, deliveredFeedback, events, false);
refreshIdleTimer();
cleanupPoll = null;
detachRequestClose();
};
const respond = async () => {
if (responding || res.writableEnded) return;
responding = true;
try {
const result = await store.takeFeedback(key);
if (result.status === "feedback") markFeedbackDelivered(key, activePolls, deliveredFeedback, events);
finishFeedbackDelivery(key, result);
if (streamHeartbeat) {
res.end(JSON.stringify(result));
} else {
Expand All @@ -443,8 +473,15 @@ export async function serve({
};
events.on("feedback", onFeedback);
events.on("ended", onFeedback);
req.on("close", cleanup);
cleanupPoll = cleanup;
if (requestClosed || req.destroyed || res.writableEnded) {
cleanup();
return;
}
timer = timeoutMs === null ? null : setTimeout(() => respond().catch(handleRespondError), timeoutMs);
} catch (error) {
cleanupPoll?.();
detachRequestClose();
next(error);
}
});
Expand Down Expand Up @@ -620,9 +657,9 @@ export async function serve({
}
events.emit("agent-reply", req.params.key, text);
// The reply concludes the delivered-feedback "working" state. Without this, a poll that
// drains feedback and then releases leaves presence stuck on "working" the chrome keeps
// Send disabled — until some future poll happens to attach, even though the agent already
// answered. See "SSE agent-presence returns to waiting after an agent reply".
// drains feedback and then releases leaves presence stuck on "working" even after the agent
// answers. Human sends remain available while working because the server queues them for the
// next poll. See "SSE agent-presence returns to waiting after an agent reply".
clearFeedbackDelivery(req.params.key, activePolls, deliveredFeedback, events);
res.json({ status: "sent" });
} catch (error) {
Expand Down
81 changes: 78 additions & 3 deletions test/chrome-client-queue.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -878,6 +878,26 @@ test("nothing is selected by default and Select all is an explicit action", asyn
assert.equal(chrome.element("warningsQueueButton").disabled, false);
});

test("warning fixes stay queueable while the agent is working", async () => {
const posts = [];
const chrome = await createChromeHarness({
fetchImpl: async (url, init) => {
posts.push({ url, body: init && init.body ? JSON.parse(init.body) : null });
return { ok: true, json: async () => ({ warnings: [warningPayload()], prompt: null }) };
},
});
chrome.eventSource().listeners.get("agent-presence")({ data: JSON.stringify({ state: "working" }) });
chrome.eventSource().listeners.get("layout-warnings")({ data: JSON.stringify({ warnings: [warningPayload()] }) });

const [row] = chrome.warningRows();
row.children[0].checked = true;
row.children[0].dispatch("change");
assert.equal(chrome.element("warningsQueueButton").disabled, false);

await chrome.element("warningsQueueButton").onclick();
assert.ok(posts.some((post) => post.url === "/api/abc/layout-warnings/queue"));
});

test("queueing a selected subset produces exactly one ordinary prompt with only those warnings", async () => {
const posts = [];
const queuedWarnings = [
Expand Down Expand Up @@ -1768,6 +1788,60 @@ test("chrome client strips the internal queue key before posting prompts", async
assert.equal(chrome.queued().length, 0);
});

test("chrome client sends queued prompts while the agent is working", async () => {
const posts = [];
const chrome = await createChromeHarness({
fetchImpl: async (url, init) => {
posts.push({ url, body: JSON.parse(init.body) });
return { ok: true };
},
});

chrome.eventSource().listeners.get("agent-presence")({ data: JSON.stringify({ state: "working" }) });
chrome.sendFrameMessage({
type: "lavish:queuePrompt",
prompt: { prompt: "Follow up", selector: "button#follow-up", tag: "choice", text: "Follow up" },
});
chrome.element("send").onclick();
assert.equal(chrome.postedToFrame.at(-1).type, "lavish:requestSnapshot");

chrome.sendFrameMessage({ type: "lavish:snapshot", snapshot: "uid=1 body" });
await flushPromises();

const submitted = posts.filter((post) => post.url === "/api/abc/prompts");
assert.equal(submitted.length, 1);
assert.deepEqual(
submitted[0].body.prompts.map((prompt) => prompt.prompt),
["Follow up"],
);
assert.equal(chrome.queued().length, 0);
});

test("send controls stay enabled while the agent works and lock only once the session ends", async () => {
const chrome = await createChromeHarness({
fetchImpl: async () => ({ ok: true, json: async () => ({}) }),
});

assert.equal(chrome.element("send").disabled, false);
assert.equal(chrome.element("sendAndEnd").disabled, false);

chrome.eventSource().listeners.get("agent-presence")({ data: JSON.stringify({ state: "working" }) });
assert.equal(chrome.element("send").disabled, false);
assert.equal(chrome.element("sendAndEnd").disabled, false);

chrome.sendFrameMessage({
type: "lavish:queuePrompt",
prompt: { prompt: "Ship this", selector: "button#ship", tag: "choice", text: "Ship" },
});
chrome.element("sendAndEnd").onclick();
chrome.sendFrameMessage({ type: "lavish:snapshot", snapshot: "uid=1 body" });
await flushPromises();
await flushPromises();

assert.equal(chrome.element("send").disabled, true);
assert.equal(chrome.element("sendAndEnd").disabled, true);
});

test("chrome send and end carries the end intent with queued prompts", async () => {
const posts = [];
const chrome = await createChromeHarness({
Expand Down Expand Up @@ -2620,7 +2694,7 @@ test("a non-array attachments field cannot wedge the queue (E5)", async () => {
assert.deepEqual(chrome.queued(), [{ prompt: "bad", selector: "h1", tag: "annotation", text: "" }]);
});

test("a poisoned prompt already in sessionStorage cannot wedge a reload (E5)", async () => {
test("a poisoned prompt already in the restored queue cannot wedge a reload (E5)", async () => {
const chrome = await createChromeHarness({
storedQueue: [{ prompt: "old poison", selector: "h1", tag: "annotation", text: "", attachments: [null] }],
});
Expand Down Expand Up @@ -2681,8 +2755,9 @@ test("a queued attachment ref is projected to primitives, not kept by reference
chrome.sendFrameMessage({ type: "lavish:snapshot", snapshot: "uid=1 body" });
await flushPromises();

assert.equal(posts.length, 1, "the queue is still sendable");
assert.deepEqual(posts[0].body.prompts[0].attachments, [{ id, name: "ok.png" }]);
const submitted = posts.filter((post) => post.url === "/api/abc/prompts");
assert.equal(submitted.length, 1, "the queue is still sendable");
assert.deepEqual(submitted[0].body.prompts[0].attachments, [{ id, name: "ok.png" }]);
});

test("a non-string attachment name is dropped rather than carried (E5)", async () => {
Expand Down
3 changes: 1 addition & 2 deletions test/fixtures/layout-audit/real-editorial.html
Original file line number Diff line number Diff line change
Expand Up @@ -231,8 +231,7 @@ <h2>Make the structure do the work</h2>
--measure-compact: 48ch;
--space-section: clamp(3rem, 8vw, 7rem);
--tone-quiet: color-mix(in oklab, currentColor 58%, transparent);
}</pre
>
}</pre>
</div>
<p>
The final test is not whether everything fits in a screenshot. It is whether the page remains composed when
Expand Down
Loading