diff --git a/README.md b/README.md index 01331043..0b4873c8 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ pnpm link 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. - 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. + 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 before feedback arrives, re-run it because feedback remains queued until delivery. Poll delivery consumes the response, so read the complete response before truncating or filtering it. 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 ` is agent-initiated. A plain `lavish-axi ` after a user-initiated end refuses to reopen the browser and returns guidance instead; pass `--reopen` only when the user asks for further review or something important needs their visual attention. diff --git a/skills/lavish/SKILL.md b/skills/lavish/SKILL.md index e43d9833..62c8816d 100644 --- a/skills/lavish/SKILL.md +++ b/skills/lavish/SKILL.md @@ -43,7 +43,7 @@ Use lavish-axi when the user asks for a visual artifact, HTML explainer, interac Never use `nohup`, shell `&`, `disown`, redirected fire-and-forget processes, or a detached terminal without an explicit verified callback merely to keep polling alive. If the harness has no completion-aware background facility, use the foreground poll or first wire a verified wake callback into the surrounding supervisor. Do not tell the user the artifact is being monitored until that wake path is live. - If the poll gets killed or times out anyway, just re-run it - queued feedback is never lost. + If the poll gets killed or times out before feedback arrives, re-run it - feedback remains queued until delivery. Poll delivery consumes the response, so read it completely. 4. If poll returns feedback, apply the user's prompts. A `layout-warnings` prompt is an explicit repair request; apply every listed fix in one pass before saving, and let Lavish re-check it after a newer artifact load. 5. Apply human feedback, then poll again with `--agent-reply ""` to reply in the browser and keep the loop going under the same foreground-or-verified-wake-path rule. 6. Run `npx -y lavish-axi end ` when the review is finished. @@ -76,7 +76,7 @@ For flows, architecture, state, or sequence diagrams, do not hand-build boxes-an - Run `npx -y lavish-axi ` to open or resume a Lavish Editor session. If the user explicitly ended the session from the browser, this refuses to reopen it and explains why instead of reopening uninvited - pass `--reopen` only when the user asks for further review or something important needs their visual attention - Unless the user specifies another location, create HTML artifacts in the current working directory under `.lavish/` - Lavish serves the html file through a local express.js server. If your html needs to reference other filesystem assets such as images, CSS, fonts, and local scripts, copy them into the same directory as the HTML file, then reference them with relative paths from that directory. Never prepend `/` to those asset paths - root paths won't work -- Run `npx -y lavish-axi poll ` to wait for user feedback. It long-polls and stays silent until the user sends feedback or ends the session, so leave it running - never kill it. Detected layout issues never return this poll: the browser files them in the user's Layout issues inbox in the Lavish top bar, and they arrive as an ordinary tag "layout-warnings" prompt only when the user selects them and queues the fixes. Never edit the artifact to chase a layout issue the user has not queued. The only exception is a fatal artifact_failures response, which means the review surface itself could not be used. Keep the poll in the foreground by default and let it return the feedback directly to the agent. A background poll is allowed only through a harness-native tracked background-job facility whose completion result is guaranteed to resume or notify the same agent. Never use `nohup`, shell `&`, `disown`, redirected fire-and-forget processes, or a detached terminal without an explicit verified callback merely to keep polling alive. If the harness has no completion-aware background facility, use the foreground poll or first wire a verified wake callback into the surrounding supervisor. Do not tell the user the artifact is being monitored until that wake path is live. If the poll gets killed or times out anyway, just re-run it - queued feedback is never lost. `Send & End` ends the session. Its final feedback is still delivered once. After that response, polling stops, and the agent must not reopen the session uninvited. +- Run `npx -y lavish-axi poll ` to wait for user feedback. It long-polls and stays silent until the user sends feedback or ends the session, so leave it running - never kill it. Detected layout issues never return this poll: the browser files them in the user's Layout issues inbox in the Lavish top bar, and they arrive as an ordinary tag "layout-warnings" prompt only when the user selects them and queues the fixes. Never edit the artifact to chase a layout issue the user has not queued. The only exception is a fatal artifact_failures response, which means the review surface itself could not be used. Keep the poll in the foreground by default and let it return the feedback directly to the agent. A background poll is allowed only through a harness-native tracked background-job facility whose completion result is guaranteed to resume or notify the same agent. Never use `nohup`, shell `&`, `disown`, redirected fire-and-forget processes, or a detached terminal without an explicit verified callback merely to keep polling alive. If the harness has no completion-aware background facility, use the foreground poll or first wire a verified wake callback into the surrounding supervisor. Do not tell the user the artifact is being monitored until that wake path is live. If the poll gets killed or times out before feedback arrives, re-run it - feedback remains queued until delivery. Poll delivery consumes the response, so read it completely. `Send & End` ends the session. Its final feedback is still delivered once. After that response, polling stops, and the agent must not reopen the session uninvited. - Rendered Mermaid diagrams in `.mermaid` containers become embedded, editable Excalidraw whiteboards in the browser (click a diagram to unlock editing; a Fullscreen action opens it over the whole viewport) - flowchart, sequence, class, ER, and state diagrams convert to editable shapes; other types embed as an image to draw on. Scenes autosave locally; an unmodified autosave silently re-converts when a reload changes the Mermaid source. If the reviewer edited the scene, they choose to re-convert and discard saved edits or keep editing the saved scene. Standalone and exported copies still render plain Mermaid. Queue feedback adds a prompt to the Conversation panel; when the user sends it, poll returns a tag "whiteboard" prompt carrying a bounded edit summary plus local scenePath (.excalidraw JSON) and previewPath (PNG) files - read the summary first, open the files only when needed, then apply the edits by updating the Mermaid source in the artifact (never try to write the scene back) - Run `npx -y lavish-axi end ` to end a session as the agent - ending it this way still allows a plain reopen later. When the user ends it from the browser instead, a later `npx -y lavish-axi ` refuses to reopen it without `--reopen` - Run `npx -y lavish-axi export [--out ]` to write a portable copy of the artifact - one HTML file with its LOCAL assets inlined - so it opens with no Lavish server and no sibling files. Remote CDN/font references are left as links, so it needs network to render those. Users can also export from the browser chrome's overflow menu diff --git a/src/cli.js b/src/cli.js index 4a55e113..c5ba1a2b 100644 --- a/src/cli.js +++ b/src/cli.js @@ -45,7 +45,7 @@ export const POLL_WAKE_PATH_RULES = Object.freeze([ "Never use `nohup`, shell `&`, `disown`, redirected fire-and-forget processes, or a detached terminal without an explicit verified callback merely to keep polling alive.", "If the harness has no completion-aware background facility, use the foreground poll or first wire a verified wake callback into the surrounding supervisor.", "Do not tell the user the artifact is being monitored until that wake path is live.", - "If the poll gets killed or times out anyway, just re-run it - queued feedback is never lost.", + "If the poll gets killed or times out before feedback arrives, re-run it - feedback remains queued until delivery. Poll delivery consumes the response, so read it completely.", ]); export const POLL_SEND_AND_END_RULE = "`Send & End` ends the session. Its final feedback is still delivered once. After that response, polling stops, and the agent must not reopen the session uninvited."; @@ -342,7 +342,7 @@ export function pollWaitBannerText(file) { return ( `[lavish-axi] Long-polling for user feedback on ${file}. This stays silent until the user sends feedback or ends the session - leave it running. ` + `Detected layout issues do NOT return this poll: they wait in the user's Layout issues inbox until the user queues them as ordinary feedback. ` + - `If it gets killed or times out, re-run \`lavish-axi poll ${file}\` - queued feedback is never lost.` + `If it gets killed or times out before feedback arrives, re-run \`lavish-axi poll ${file}\` - feedback remains queued until delivery. Poll delivery consumes the response, so read it completely.` ); } @@ -354,7 +354,7 @@ export function pollWaitTickText(elapsedMs) { export function pollInterruptedText(file) { return ( `[lavish-axi] Poll interrupted before user feedback arrived. The user may still be reviewing - ` + - `re-run \`lavish-axi poll ${file}\` to keep waiting; queued feedback is never lost.` + `re-run \`lavish-axi poll ${file}\` to keep waiting; feedback remains queued until delivery. Poll delivery consumes the response, so read it completely.` ); } @@ -380,10 +380,10 @@ export function startPollWaitReporter({ /** * @returns {{ * session: { file: string, status: string, session_ended?: boolean, ended_by?: string }, - * next_step?: string, - * dom_snapshot?: string, * prompts?: any[], * artifact_failures?: any[], + * dom_snapshot?: string, + * next_step?: string, * }} */ export function createPollOutput({ file, response, agent = "generic" }) { @@ -402,9 +402,9 @@ export function createPollOutput({ file, response, agent = "generic" }) { status: "feedback", ...(sessionEnded ? { session_ended: true, ...(endedBy ? { ended_by: endedBy } : {}) } : {}), }, - dom_snapshot: response.dom_snapshot || "", prompts: response.prompts || [], ...(artifactFailures.length > 0 ? { artifact_failures: artifactFailures } : {}), + dom_snapshot: response.dom_snapshot || "", next_step: createFeedbackNextStep(file, artifactFailures, sessionEnded, endedBy, response.prompts || [], agent), }; } @@ -416,7 +416,7 @@ export function createPollOutput({ file, response, agent = "generic" }) { } return { session: { file, status: response.status || "waiting" }, - next_step: `No user feedback arrived before the optional timeout. Run \`lavish-axi poll ${file}\` without --timeout-ms to wait indefinitely - queued feedback is never lost, so re-running the poll is always safe.`, + next_step: `No user feedback arrived before the optional timeout. Run \`lavish-axi poll ${file}\` without --timeout-ms to wait indefinitely - feedback remains queued until delivery, so re-running the poll is safe while waiting. Poll delivery consumes the response, so read it completely.`, }; } diff --git a/test/cli-output.test.js b/test/cli-output.test.js index 501a09d9..1f83b1c4 100644 --- a/test/cli-output.test.js +++ b/test/cli-output.test.js @@ -270,7 +270,7 @@ test("home output warns agents that poll needs an observable wake path", () => { assertObservablePollWakePath(pollHelp); assert.doesNotMatch(pollHelp, /Codex/); assert.match(pollHelp, /re-run/); - assert.match(pollHelp, /queued feedback is never lost/); + assert.match(pollHelp, /feedback remains queued until delivery/); assert.match(pollHelp, /`Send & End` ends the session/); assert.match(pollHelp, /final feedback is still delivered once/); assert.doesNotMatch(pollHelp, /above 10 minutes/); @@ -301,7 +301,7 @@ test("home output keeps static skill poll guidance safe and agent-neutral", () = assertObservablePollWakePath(pollHelp); assert.doesNotMatch(pollHelp, /keep the poll attached to the active turn/i); assert.doesNotMatch(pollHelp, /Codex detected/); - assert.match(pollHelp, /queued feedback is never lost/); + assert.match(pollHelp, /feedback remains queued until delivery/); }); test("invoking agent detection recognizes Codex runtime markers only", () => { @@ -334,7 +334,7 @@ test("top-level help renders static home output without dynamic sessions", async assert.match(result.stdout, /lavish-axi design/); assert.match(result.stdout, /strict priority order/); assert.match(result.stdout, /never kill it/); - assert.match(result.stdout, /queued feedback is never lost/); + assert.match(result.stdout, /feedback remains queued until delivery/); assert.doesNotMatch(result.stdout, /above 10 minutes/); assert.doesNotMatch(result.stdout, /lavish-design/); assert.doesNotMatch(result.stdout, /sessions\[/); @@ -773,7 +773,7 @@ test("open output keeps the user URL in session data and next_step focused on po assert.match(output.next_step, /never kill it/); assertObservablePollWakePath(output.next_step); assert.doesNotMatch(output.next_step, /Codex/); - assert.match(output.next_step, /queued feedback is never lost/); + assert.match(output.next_step, /feedback remains queued until delivery/); assert.match(output.next_step, /Do not pass --timeout-ms/); assert.match(output.next_step, /If the user ends the session, stop polling and do not reopen it/); assert.match(output.next_step, /--reopen/); @@ -1116,7 +1116,7 @@ test("poll help requires an observable wake path", () => { assert.match(help, /never kill it/); assertObservablePollWakePath(help); assert.doesNotMatch(help, /Codex/); - assert.match(help, /queued feedback is never lost/); + assert.match(help, /feedback remains queued until delivery/); assert.match(help, /Do not pass --timeout-ms/); assert.match(help, /tests and debugging only/); assert.match(help, /`Send & End` ends the session/); @@ -1161,11 +1161,76 @@ test("feedback next step keeps the next poll completion observable", () => { assert.match(output.next_step, /without --timeout-ms/); assertObservablePollWakePath(output.next_step); assert.doesNotMatch(output.next_step, /Codex/); - assert.match(output.next_step, /queued feedback is never lost/); + assert.match(output.next_step, /feedback remains queued until delivery/); assert.match(output.next_step, /Do not respond to the user just yet\. Now you must run/); assert.doesNotMatch(output.next_step, /above 10 minutes/); }); +test("poll feedback is emitted before artifact failures and the bulky DOM snapshot", async () => { + const stateDir = await mkdtemp(`${os.tmpdir()}/lavish-axi-poll-output-test-`); + const artifact = `${stateDir}/artifact.html`; + await writeFile(artifact, "hello", "utf8"); + const response = { + status: "feedback", + prompts: [{ prompt: "Ship it", tag: "message" }], + artifact_failures: [{ kind: "artifact-unavailable", detail: "HTTP 404", severity: "fatal" }], + dom_snapshot: "large snapshot", + }; + const server = createServer((req, res) => { + if (req.url === "/health") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true, app: "lavish-axi", version: VERSION })); + return; + } + if (req.url?.startsWith("/api/poll?")) { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(response)); + return; + } + res.writeHead(404); + res.end(); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + try { + const address = server.address(); + assert.ok(address && typeof address !== "string"); + const child = spawn( + process.execPath, + [fileURLToPath(new URL("../bin/lavish-axi.js", import.meta.url)), "poll", artifact, "--timeout-ms", "1000"], + { + cwd: fileURLToPath(new URL("..", import.meta.url)), + env: { ...process.env, LAVISH_AXI_STATE_DIR: stateDir, LAVISH_AXI_PORT: String(address.port) }, + }, + ); + let stdout = ""; + let stderr = ""; + assert.ok(child.stdout); + assert.ok(child.stderr); + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + const result = await new Promise((resolve) => { + child.on("close", (status, signal) => resolve({ status, signal })); + }); + + assert.equal(result.status, 0, stderr); + const promptsIndex = stdout.indexOf("prompts["); + const failuresIndex = stdout.indexOf("artifact_failures["); + const snapshotIndex = stdout.indexOf("dom_snapshot:"); + assert.ok(promptsIndex >= 0, "poll stdout contains prompts"); + assert.ok(failuresIndex >= 0, "poll stdout contains artifact_failures"); + assert.ok(snapshotIndex >= 0, "poll stdout contains dom_snapshot"); + assert.ok(promptsIndex < failuresIndex, "prompts precede artifact_failures in poll stdout"); + assert.ok(failuresIndex < snapshotIndex, "artifact_failures precede dom_snapshot in poll stdout"); + } finally { + await new Promise((resolve) => server.close(resolve)); + await rm(stateDir, { force: true, recursive: true }); + } +}); + test("feedback next step is Codex-aware when requested", () => { const output = createPollOutput({ file: "/tmp/report.html", @@ -1442,7 +1507,7 @@ test("poll wait messages tell watching agents the silence is normal", () => { assert.match(banner, /Long-polling for user feedback/); assert.match(banner, /stays silent/); assert.match(banner, /leave it running/i); - assert.match(banner, /queued feedback is never lost/); + assert.match(banner, /feedback remains queued until delivery/); const tick = pollWaitTickText(3 * 60_000); assert.match(tick, /\[lavish-axi\]/); @@ -1454,7 +1519,7 @@ test("poll wait messages tell watching agents the silence is normal", () => { assert.match(interrupted, /Poll interrupted/); assert.match(interrupted, /user may still be reviewing/); assert.match(interrupted, /lavish-axi poll \/tmp\/report\.html/); - assert.match(interrupted, /queued feedback is never lost/); + assert.match(interrupted, /feedback remains queued until delivery/); }); test("poll wait reporter writes a banner immediately and heartbeats on an interval", async () => { @@ -1559,7 +1624,7 @@ test("spawned poll with piped stderr banners once and leaves re-run guidance whe // to the child process's JavaScript signal handler. if (process.platform !== "win32") { assert.match(stderr, /Poll interrupted/); - assert.match(stderr, /queued feedback is never lost/); + assert.match(stderr, /feedback remains queued until delivery/); } } finally { await server.close(); @@ -1575,7 +1640,7 @@ test("waiting next step reassures agents that re-running poll loses nothing", () assert.match(output.next_step, /lavish-axi poll \/tmp\/report\.html/); assert.match(output.next_step, /without --timeout-ms/); - assert.match(output.next_step, /queued feedback is never lost/); + assert.match(output.next_step, /feedback remains queued until delivery/); }); test("html file arguments normalize to the hidden open command", () => { diff --git a/test/skill.test.js b/test/skill.test.js index 8d5b59d5..61fae292 100644 --- a/test/skill.test.js +++ b/test/skill.test.js @@ -129,7 +129,7 @@ test("createSkillMarkdown requires an observable wake path for every poll", () = assert.match(workflow, /Do not tell the user the artifact is being monitored until that wake path is live/i); assert.match(workflow, /`Send & End` ends the session.*final feedback is still delivered once.*polling stops/i); assert.match(workflow, /(?:do|must) not reopen (?:it|the session) uninvited/i); - assert.match(workflow, /queued feedback is never lost/); + assert.match(workflow, /feedback remains queued until delivery/); assert.doesNotMatch(md, /Codex detected/); });