diff --git a/AGENTS.md b/AGENTS.md index 2bd8d49b..33c3a7c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ That makes the session key **derived, not secret**: anyone who learns the artifa The narrow **fatal** path is separate and still immediate: `/api/:key/artifact-failures` records only `artifact-unavailable` (the chrome's probe of the artifact route failed) or `artifact-asset-unavailable` (the SDK saw a same-origin subresource `error`), marks the session `feedback`, and `takeFeedback` returns them as `artifact_failures`. Ordinary layout findings must never be relabelled fatal to regain auto-repair. 6. User actions in the iframe `postMessage` to the chrome. Queued prompts live in tab `sessionStorage`; unsent prompts sharing the SDK-internal `_lavishQueueKey` replace each other, that field is stripped before POSTing collected prompts to `/api/:key/prompts`, and sent prompts are removed only after a successful response. `/api/:key/prompts` is **same-origin guarded** like `/share` and the whiteboard write routes: whatever lands there reaches the agent as the reviewer's own instructions, and the key alone must never buy that. Only this server's own chrome may queue prompts, so any test or tool that posts there has to send a matching `Origin`; behind a reverse proxy, the expected origin is built from the outermost `X-Forwarded-Host`'s validated hostname and port plus the outermost `X-Forwarded-Proto`, only after the forwarded authority passes the same allowlist boundary as `Host` (or strict authority validation under the `*` opt-out). - The route rejects every new batch for an already-ended session, including a redundant send-and-end batch, because no later poll will deliver it. `SessionStore.queuePrompts` exempts only its internal `restore` replay of a batch that was accepted before a poll disconnected. + The route rejects every new batch for an already-ended session, including a redundant send-and-end batch, because no later poll will deliver it. The chrome page (`/session/:key`) also answers `X-Frame-Options: DENY` and `frame-ancestors 'none'`, denying an attacker page both a window handle to the chrome and a clickjacking surface over Send. The header is scoped to that route on purpose - `/artifact/*` is framed by the chrome, and `/whiteboard-frame` is framed by the artifact document (see the whiteboard section). Text selection prompts use `tag: "text"` with a `target` of `type: "text-range"` (selected text, `commonAncestorSelector`, start/end boundary anchors). Mermaid diagram-node prompts for rendered SVGs outside `.mermaid` containers use `tag: "mermaid-node"` with a `target` carrying `diagramId`, `nodeId`, the rendered `label`, and a `selector`, so the annotation anchors to node identity and survives a re-render that reshuffles the SVG. @@ -84,10 +84,12 @@ That makes the session key **derived, not secret**: anyone who learns the artifa A user-queued layout-issue batch arrives here as an ordinary `tag: "layout-warnings"` prompt with a `type: "layout-warnings"` target - indistinguishable from other feedback at the CLI boundary, which is what lets a nonblocking relay consume it without special handling. `layout_warnings` is no longer a poll field. 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. - Key order in `createPollOutput`'s feedback object is a contract, not incidental: `prompts`, `artifact_failures`, then `next_step` all come before the unbounded `dom_snapshot`, so an agent that truncates or filters the response still reaches the user's own words and the instruction that continues the review loop (`test/cli-output.test.js` guards the emitted order). Never move `next_step` after the snapshot: it is the only field carrying the reply-and-poll-again directive, the attachment paths, and the session-ended "stop polling" rule. Poll delivery consumes the response - the feedback is gone from the store once returned - which is why the guidance strings say feedback remains queued only _until delivery_ rather than that it is never lost. - 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. - That durability has to survive the take itself: `takeFeedback` clears the batch before any response is written, so a poll whose client disconnected - during the immediate take OR during the event-driven long-poll `respond()` - puts the prompts, `dom_snapshot`, and `artifact_failures` back through `queuePrompts`'s `restore` mode and leaves delivery unmarked, because nothing was delivered. `restore` replays an already-accepted batch verbatim - it re-plans no layout warnings and re-appends no chat messages - while still re-deriving attachments through the same resolver. - Everything else about restore exists because the take window is concurrent, and each rule is load-bearing. Restored prompts are PREPENDED, so a `/prompts` that landed in the window keeps its later position and the user's chronological order survives. A newer `dom_snapshot` and newer `artifact_failures` WIN - the restore only fills what the window did not already replace, and the failure merge goes through the same `mergeArtifactFailures` dedupe and `MAX_ARTIFACT_FAILURES` bound as a fresh report, so a failure re-reported inside the window is not handed to the agent twice - including the end the bound trims, because the restore REPLACES that list: keeping its own never-delivered entries over the newer ones would delete failures nothing else holds, recorded in the window and never delivered either. At the bound the restore's own overflow is dropped and reported through the incomplete-restore log. A successful restore must re-emit `feedback`: a second poll can take `waiting` between the destructive take and the restore, and without that wake it long-polls forever over feedback already sitting in `state.json`. And a restore is exempt from the request-wide `MAX_REQUEST_ATTACHMENT_REFS` bound (never from the per-prompt cap or the resolver), because that bound sizes ONE untrusted POST while a delivery accumulates across an unbounded number of them - measuring a restore against it rejects the whole batch and causes exactly the loss restore exists to prevent. + Key order in `createPollOutput`'s feedback object is a contract, not incidental: `prompts`, `delivery_id`, `artifact_failures`, then `next_step` all come before the unbounded `dom_snapshot`, so an agent that truncates or filters the response still reaches the user's own words, delivery identity, and the instruction that continues the review loop (`test/cli-output.test.js` guards the emitted order). + Never move `next_step` after the snapshot: it is the only field carrying the ACK/reply-and-poll-again directive, the attachment paths, and the session-ended "stop polling" rule. + `takeFeedback` persists one `feedback_delivery` before returning it, and an active lease suppresses duplicate delivery until its 30-second timeout releases the same batch and `delivery_id` again. + `acknowledgeFeedback` is the only path that clears a delivered prompt prefix, matching artifact failures, and an exhausted DOM snapshot; newer feedback remains queued, and the ACK route emits `feedback` when that remainder must wake a standing poll. + ACK ids and ACK-associated agent-reply ids are retained in bounded histories so retrying after a lost response is idempotent. + If SIGINT or SIGTERM interrupts a no-timeout poll, the CLI writes re-run guidance to stderr and exits with the conventional signal code; unacknowledged feedback remains retained and is released when the lease expires. 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; while the session is open, 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. Presence is server-derived: a 200 from `/api/:key/prompts` acknowledges the queue write, not a completed round, so the chrome never promotes itself to `working` on send and renders only the state the stream reports. On the server, only a poll attaching with no other poll in flight retires the previous round's delivery (`setPollActive`); a poll releasing, or attaching beside a sibling, must not - either erases a delivery an overlapping poll just recorded and reports a working agent as merely waiting. An `ended` SSE event makes every connected chrome read-only immediately. The stream registers listeners before reading session state, then sends an `ended` snapshot when that state is already terminal; the bootstrapped `initialEnded` state covers pages loaded after the end, and `markSessionEnded()` is idempotent across redundant signals. @@ -146,7 +148,7 @@ Four more composer rules are easy to regress: the chip send gate holds back only The upload route reads the request stream itself via `readAttachmentUploadBody` rather than `express.raw({ limit })`: raw-body aborts on a too-large `Content-Length` WITHOUT draining, which leaves the browser's in-flight upload reset mid-stream so the chip hangs on "uploading" instead of getting the 413. The manual reader buffers up to the cap but always drains to end-of-body, then the route sends a clean 413. Belt-and-suspenders, the chrome also pre-checks byte length against `attachmentMaxBytes` (surfaced in the chrome session JSON) and fails the chip locally before uploading. Do not reintroduce a body-parser `limit` on this route. Because the sandboxed frame's postMessage source proves origin but not a user gesture, the chrome is the **confused-deputy mediation point** for the relayed card path, and its counters are page-wide, so the composer's own user-gesture uploads spend the same budget: it rate-limits (`UPLOAD_RATE_MAX`/`UPLOAD_RATE_WINDOW_MS`), enforces a per-chrome-session cumulative-byte quota (`UPLOAD_SESSION_BYTE_QUOTA`), AND bounds concurrent in-flight uploads (`UPLOAD_MAX_IN_FLIGHT`, D8 - rate + cumulative alone let ~30 large bodies fetch at once before the quota tripped) before any upload reaches the loopback server; over-cap uploads are refused with a retry hint and a settled upload frees a slot. The server keeps a bounded default disk quota (`DEFAULT_MAX_ATTACHMENT_DISK_BYTES`) as the durable backstop. The **trust boundary** is `SessionStore.queuePrompts`: a queued prompt carries only the client's `id` + display `name`; `resolvePromptAttachments` re-derives every authoritative field (absolute `path`, mime, bytes, dimensions) from disk via the injected resolver, so a crafted `/prompts` POST cannot aim an attachment at an arbitrary file. Repeated content ids remain repeated logical references with their own display names and count separately toward prompt count/byte caps; content-addressed storage deduplicates only the bytes on disk. Resolution is **all-or-nothing** (C4): a malformed field/entry, any unknown id, or a count/byte-cap breach rejects the whole batch (`{ rejected, caps }`, persist nothing → 400), never a silent partial-drop; the chrome keeps its queue on the 400 and surfaces the reason. User-facing limits/env vars and defaults are owned by README's Image attachments bullet. -`boundAttachmentRefs` runs BEFORE the resolver and is why a crafted batch can't wedge the server: the in-resolver cap counts RESOLVED refs, and a well-formed id for a file that doesn't exist never advances it, so thousands of them would each buy a sequential `stat` while the store's single mutex is held, stalling every poll and mutation. Raw per-prompt and request-wide (`MAX_REQUEST_ATTACHMENT_REFS`) counts are rejected up front - except that a closed-poll restore is exempt from the request-wide bound alone, for the reason given in Request flow step 7 - and the reported rejection list is capped (`MAX_REPORTED_ATTACHMENT_REJECTIONS`) so the 400 can't be turned into an amplifier. New pre-resolve validation belongs in that same pure gate - never after the first `await` into the filesystem. +`boundAttachmentRefs` runs BEFORE the resolver and is why a crafted batch can't wedge the server: the in-resolver cap counts RESOLVED refs, and a well-formed id for a file that doesn't exist never advances it, so thousands of them would each buy a sequential `stat` while the store's single mutex is held, stalling every poll and mutation. Raw per-prompt and request-wide (`MAX_REQUEST_ATTACHMENT_REFS`) counts are rejected up front, and the reported rejection list is capped (`MAX_REPORTED_ATTACHMENT_REJECTIONS`) so the 400 can't be turned into an amplifier. New pre-resolve validation belongs in that same pure gate - never after the first `await` into the filesystem. `SessionStore` owns ONE `AsyncMutex` (`src/async-mutex.js`, `store.lock`) covering BOTH concerns. Every `state.json` read-modify-write runs under it - `queuePrompts`, `takeFeedback`, `recordLayoutWarnings`, `upsertSession`, `endSession`, `addAgentReply` - so a poll can't clear prompts in the window `queuePrompts` holds its pre-resolve snapshot and then clobber the take (E1); **any new store mutation must acquire this lock too**. The server routes its attachment disk sections (upload finalize, delete, the sweep) through `store.runExclusive` so they share that same lock, keeping the reference snapshot + delete atomic against `queuePrompts` (D5). `referencedAttachmentIds` is a pure read and must stay lock-free - the server calls it from inside `runExclusive`, so self-locking would deadlock. Delete is **reference-counted** under the lock - a content-addressed file shared by a queued prompt survives a chip removal (`status: "referenced"`) - and dedup re-upload **refreshes the mtime** so a re-referenced aged file isn't reaped by the next sweep (B3). Dimensions are persisted at upload in a `.meta` sidecar (D6): `resolveAttachment` reads it instead of re-parsing the whole image, and the thumbnail GET uses `statAttachmentForServe` (one stat + streamed send, mime from the id extension) instead of a second full read. Sidecars are removed with their image on delete/sweep and are excluded from `listAttachments` by `ID_RE`. The SDK's annotation card gates queuing on BOTH `hasPending()` (R2.4, an in-flight upload) AND `hasErrors()` (W2, a failed/rejected chip) so neither is silently dropped by `collectReady`/`closeCard` - it keeps the card open with a notice so the user can wait, retry, or explicitly remove; that notice line is shared with the card's neutral keyboard hint, so it renders in the error color and is restored (never left stale) once the condition clears. A count-cap rejection remains visible while the card is full and clears only when removing a chip creates capacity. A mixed drop **partial-accepts**: `partitionDroppedFiles` always reports both halves, so the images attach AND every unsupported companion raises its own `UNSUPPORTED_TYPE` chip - reporting the unsupported files only when no image was found is what made a mixed drop swallow them silently. Removing a chip deletes **nothing**: there is deliberately no `lavish:removeAttachment` message and the chrome honors no iframe-driven delete, because the iframe is untrusted and no client can see every live reference (attachments are content-addressed, so another tab's ready-but-unqueued chip shares the id and is invisible from here). Reclamation belongs solely to the reference-aware sweeper; do not reintroduce an eager delete. Upload results are scoped to the document that asked for them: each document mints an `ATTACHMENT_NONCE`, sends it with every upload, and `isTrustedAttachmentResult` requires both an exact nonce match and `event.source === parent` - chip ids restart at `att-1` on every load, so id alone would let a result in flight across a reload mark a new chip with the previous document's image. The card's per-prompt count cap is NOT a literal: `createSdkJs` threads the server's `maxPerPrompt` into the SDK so the local guard matches `LAVISH_AXI_MAX_ATTACHMENTS_PER_PROMPT` (W1), and an over-cap pick is surfaced instantly rather than swallowed. Queued-prompt pills always render at most four thumbnails and collapse every additional attachment into an explicit `+N` badge, independent of that configurable cap (`test/chrome-client-queue.test.js`). The card re-clamps its viewport position after every attachment-row render (W3) so chip rows can't push Queue/Cancel off-frame; the chip list also has a `max-height`/scroll backstop. diff --git a/README.md b/README.md index e83fa86e..c205c19c 100644 --- a/README.md +++ b/README.md @@ -203,14 +203,18 @@ 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. Escape closes the card, same as Cancel, but only while it is empty (no text, no attachment); with unsent text or an attachment present, Escape does nothing rather than risk discarding it. 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 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 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. +- **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. + Every feedback response carries a stable `delivery_id`; after processing it, pass `--ack ` on the next poll. + Until ACK, the batch remains in `state.json`; an unacknowledged lease expires after 30 seconds and releases the identical batch for redelivery. + The agent's ACK-associated reply (`--agent-reply`) is idempotent, 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 its output is lost, re-run it and the unacknowledged batch will be released for redelivery. 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. When either side ends the session, every open review tab becomes visibly read-only and disables its feedback controls; feedback submitted after the end is refused instead of being accepted without an agent to receive it. 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. Agent-initiated ends keep reopening normally, same as before. - `lavish-axi poll`'s `ended` response and the `feedback` response for the final batch before an end both carry `next_step` guidance telling the agent to stop polling and deliver remaining updates in chat instead of reopening. + `lavish-axi poll`'s `ended` response and the `feedback` response for the final batch before an end both carry `next_step` guidance telling the agent to acknowledge the final delivery, then stop polling and deliver remaining updates in chat instead of reopening. - **Precise targets** - Text annotations include selected text plus range anchors, and text selections carry those anchors only. Clicking an element inside a table also carries the cell's visible row and column names alongside the exact CSS locator, so filtered or sorted rows do not make feedback look misdirected. When merged cells make either name ambiguous, Lavish leaves that name out rather than guessing; an explicit `` remains authoritative even when a `rowspan` makes the row's position ambiguous. @@ -235,7 +239,7 @@ pnpm link Set `LAVISH_AXI_IDLE_TIMEOUT_MS=0` or `off` to disable idle self-shutdown. - **Server upgrades** - One background server serves every session, so upgrading `lavish-axi` while reviews are open makes the next `lavish-axi ` replace that server. Only the review page for the artifact being opened reloads itself once the replacement answers - and not even that one while you have unsent annotation text open, which gets the same banner instead so the reload is yours to make. Every other open review page keeps working and shows a banner reading "Lavish was updated. This page is running the previous version.", with a Check and reload button and a Dismiss button, so no page you are reading reloads on its own. After `lavish-axi stop` those pages say Lavish was stopped and to reload after you start it again, and a restart that only picks up a local build says that rather than claiming an update. Every **Check and reload** control asks the server whether it is running before it navigates; while nothing answers, the page stays where it is and says so, and a check that gets no answer at all says that instead of guessing. - In-flight `lavish-axi poll` commands end with an interrupted-poll error and are safe to re-run; queued feedback is never lost, and annotation text you have typed but not queued yet survives the reload as described under **Live reload**. + In-flight `lavish-axi poll` commands end with an interrupted-poll error and are safe to re-run; unacknowledged feedback is released for redelivery when its lease expires, and annotation text you have typed but not queued yet survives the reload as described under **Live reload**. A page waits for the replacement rather than reloading into a port nothing is listening on, and tells the user to restart Lavish if it never returns. - **Local-first state** - Session state stays under `~/.lavish-axi/` by default, or `LAVISH_AXI_STATE_DIR` when set. - **Diagnostic viewports** - `LAVISH_AXI_DIAGNOSTIC_VIEWPORTS` sets which viewport classes the layout-issue inbox tracks (`mobile`, `compact`, `desktop`; comma-separated, default all). Warnings whose class leaves the set are marked obsolete with an explicit reason instead of silently reading as fixed. @@ -246,21 +250,21 @@ pnpm link ## CLI Reference -| Command | Description | -| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `lavish-axi` | Show current sessions and usage guidance. | -| `lavish-axi update` | Check for or apply the latest npm release through the AXI SDK self-updater. | -| `lavish-axi ` | Open or resume a Lavish Editor session, with the open-time layout gate enabled by default. Unresolved layout issues from earlier in the session are preserved. Refuses to reopen a session the user explicitly ended from the browser unless `--reopen` is passed. | -| `lavish-axi poll ` | Long-poll until the user sends feedback or ends the session; detected layout issues wait in the user's Layout issues inbox and arrive only when queued. Leave no-timeout polls running, or re-run them if interrupted. Codex guidance keeps polls attached to the active turn. On `status: ended`, stop polling and do not reopen uninvited. | -| `lavish-axi end ` | End a session as the agent; unlike a user-initiated end from the browser, this still allows a plain reopen later. | -| `lavish-axi export ` | Write a portable copy of the artifact: one HTML file with its local assets inlined, so it opens with no server and no sibling files. Remote CDN/font references are left as links. | -| `lavish-axi share ` | Publish the artifact (local assets inlined) to [ht-ml.app](https://ht-ml.app), a third-party host not part of Lavish, and print a visitable URL plus a secret update key; shares are public by default, `--private` locks one behind a generated password, and the same command republishes or unpublishes an existing page with `--site`/`--update-key`. | -| `lavish-axi stop` | Shut down the background server. | -| `lavish-axi playbook [id]` | List focused artifact guidance or show one playbook; agents must open each matching playbook before writing HTML. | -| `lavish-axi design` | Show agent-facing design guidance, including optional CDN snippets and the whiteboard (Mermaid) opt-in snippet. | -| `lavish-axi setup hooks` | Install or repair optional SessionStart hooks for Claude Code, Codex, OpenCode, and GitHub Copilot CLI; restart the agent session afterward. | -| `lavish-axi setup plugin` | Register the installed package as an [Agent Plugin](https://agent-plugins.org) in VS Code, Cursor, and GitHub Copilot CLI; opt-in, idempotent, no marketplace involved. Reload each client afterward. | -| `lavish-axi server` | Run the local Lavish Editor server. | +| Command | Description | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `lavish-axi` | Show current sessions and usage guidance. | +| `lavish-axi update` | Check for or apply the latest npm release through the AXI SDK self-updater. | +| `lavish-axi ` | Open or resume a Lavish Editor session, with the open-time layout gate enabled by default. Unresolved layout issues from earlier in the session are preserved. Refuses to reopen a session the user explicitly ended from the browser unless `--reopen` is passed. | +| `lavish-axi poll ` | Long-poll until the user sends feedback or ends the session; detected layout issues wait in the user's Layout issues inbox and arrive only when queued. Feedback is leased under a stable `delivery_id` and released for redelivery until ACK. Leave no-timeout polls running, or re-run them if interrupted. Codex guidance keeps polls attached to the active turn. On `status: ended`, stop polling and do not reopen uninvited. | +| `lavish-axi end ` | End a session as the agent; unlike a user-initiated end from the browser, this still allows a plain reopen later. | +| `lavish-axi export ` | Write a portable copy of the artifact: one HTML file with its local assets inlined, so it opens with no server and no sibling files. Remote CDN/font references are left as links. | +| `lavish-axi share ` | Publish the artifact (local assets inlined) to [ht-ml.app](https://ht-ml.app), a third-party host not part of Lavish, and print a visitable URL plus a secret update key; shares are public by default, `--private` locks one behind a generated password, and the same command republishes or unpublishes an existing page with `--site`/`--update-key`. | +| `lavish-axi stop` | Shut down the background server. | +| `lavish-axi playbook [id]` | List focused artifact guidance or show one playbook; agents must open each matching playbook before writing HTML. | +| `lavish-axi design` | Show agent-facing design guidance, including optional CDN snippets and the whiteboard (Mermaid) opt-in snippet. | +| `lavish-axi setup hooks` | Install or repair optional SessionStart hooks for Claude Code, Codex, OpenCode, and GitHub Copilot CLI; restart the agent session afterward. | +| `lavish-axi setup plugin` | Register the installed package as an [Agent Plugin](https://agent-plugins.org) in VS Code, Cursor, and GitHub Copilot CLI; opt-in, idempotent, no marketplace involved. Reload each client afterward. | +| `lavish-axi server` | Run the local Lavish Editor server. | Known playbook IDs: `diagram`, `table`, `comparison`, `plan`, `code`, `input`, `slides`. One artifact often combines several playbooks, such as a plan that includes a comparison and a diagram, so agents must match against each `use_when` trigger and open every matching playbook before writing HTML. @@ -281,7 +285,8 @@ For flows, architecture, state, or sequence diagrams, open the diagram playbook | `lavish-axi share` | `--update-key ` | The secret returned when the page was published; required to republish or unpublish it. | | `lavish-axi share` | `--unpublish` | Replace a published page with a locked placeholder (ht-ml.app cannot delete); takes `--site` and `--update-key` and no file. | | `lavish-axi share` | `--token ` | Attach an optional bearer token (`LAVISH_AXI_HTML_APP_TOKEN`) when creating a page; never required, and rejected on a republish or `--unpublish`, where the `update_key` is the credential. | -| `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` | `--ack ` | Acknowledge a processed feedback delivery before waiting for the next batch. Repeating the same ACK is safe. | +| `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. Pair it with `--ack` after feedback so retrying the combined command cannot duplicate the reply. | | `lavish-axi poll` | `--timeout-ms ` | Test/debug escape hatch only; agents should normally omit it and leave the long poll running. | | `lavish-axi stop` | `--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. | diff --git a/src/cli.js b/src/cli.js index f5a063b8..9305d282 100644 --- a/src/cli.js +++ b/src/cli.js @@ -54,10 +54,11 @@ 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 before feedback arrives, re-run it - feedback remains queued until delivery. Poll delivery consumes the response, so read it completely.", + "Every feedback response has a delivery_id. Process it completely, then pass `--ack ` on the next poll; an unacknowledged batch is released for redelivery when its lease expires.", + "If a poll is interrupted, re-run it; the lease prevents that interruption from destroying feedback.", ]); 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."; + "`Send & End` ends the session. Its final feedback remains leased until ACK; acknowledge it before polling stops, and do not reopen the session uninvited."; const CODEX_POLL_WAKE_PATH_GUIDANCE = "Codex detected: completed background tasks may not resume Codex automatically, so keep the poll attached to the active turn."; // Inlined at build time from package.json; falls back to reading package.json so source-run tests work. @@ -196,7 +197,7 @@ export function createHomeOutput({ bin, sessions, includeSessions = true, agent "Run `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 \`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. ${pollExecutionGuidance({ agent })} ${POLL_SEND_AND_END_RULE}`, + `Run \`lavish-axi poll \` to wait for user feedback. Every feedback batch has a stable delivery_id; after processing it, pass \`--ack \` on the next poll. Unacknowledged feedback is released for redelivery when its lease expires. 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. ${pollExecutionGuidance({ agent })} ${POLL_SEND_AND_END_RULE}`, 'Mermaid is the whiteboard opt-in, not the diagram default: only when the user asks for an editable whiteboard, author that diagram as Mermaid in a `.mermaid` container. Rendered Mermaid diagrams there 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 `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 `lavish-axi ` refuses to reopen it without `--reopen`", "Run `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", @@ -243,7 +244,7 @@ export function createOpenOutput({ session: { file, url, status }, ...(networkWarning ? { network_warning: networkWarning } : {}), ...(selfPaintWarning ? { self_paint_warning: selfPaintWarning } : {}), - next_step: `${selfPaintPrefix}Do not respond to the user just yet. Now you must run \`lavish-axi poll ${file}\`. This command long-polls until the user sends feedback or ends the session, and it stays silent the whole time - that is normal, never kill it. Layout issues the browser detects do not return this poll; they wait in the user's Layout issues inbox until the user queues them, then arrive as an ordinary tag "layout-warnings" prompt. Do not pass --timeout-ms during normal agent use. ${pollExecutionGuidance({ agent })} After applying feedback, run \`lavish-axi poll ${file} --agent-reply ""\` without --timeout-ms to show your response in Lavish Editor and wait for more feedback. If the user ends the session, stop polling and do not reopen it by re-running \`lavish-axi ${file}\` unless the user asks for further review or something genuinely important needs their visual attention - deliver routine updates directly in this conversation instead. When reopening is warranted, run \`lavish-axi ${file} --reopen\`.`, + next_step: `${selfPaintPrefix}Do not respond to the user just yet. Now you must run \`lavish-axi poll ${file}\`. This command long-polls until the user sends feedback or ends the session, and it stays silent the whole time - that is normal, never kill it. Layout issues the browser detects do not return this poll; they wait in the user's Layout issues inbox until the user queues them, then arrive as an ordinary tag "layout-warnings" prompt. Do not pass --timeout-ms during normal agent use. ${pollExecutionGuidance({ agent })} After applying feedback, run \`lavish-axi poll ${file} --ack --agent-reply ""\` without --timeout-ms to acknowledge that delivery, show your response in Lavish Editor, and wait for more feedback. If the user ends the session, acknowledge its final delivery before stopping and do not reopen it unless the user asks for further review or something genuinely important needs their visual attention. When reopening is warranted, run \`lavish-axi ${file} --reopen\`.`, }; } @@ -314,15 +315,33 @@ export function shouldOpenBrowser(args, env) { } async function pollCommand(args) { - const file = firstPositionalArg(args, ["--agent-reply", "--timeout-ms"]); + const file = firstPositionalArg(args, ["--ack", "--agent-reply", "--timeout-ms"]); if (!file) { throw new AxiError("HTML file path is required", "VALIDATION_ERROR", ["Run `lavish-axi poll `"]); } const absolute = await canonicalFile(file); const baseUrl = await ensureServer(); + const deliveryId = flagValue(args, "--ack"); + if (deliveryId && !/^[0-9a-f]{16}$/.test(deliveryId)) { + throw new AxiError("--ack must be a 16-character delivery id", "VALIDATION_ERROR", [ + `Use the delivery_id returned by \`lavish-axi poll ${absolute}\``, + ]); + } + if (deliveryId) { + await postJson( + `${baseUrl}/api/${sessionKey(absolute)}/ack`, + { delivery_id: deliveryId }, + { + connectionFailureSuggestion: `Re-run \`lavish-axi poll ${absolute.replaceAll("\\", "/")} --ack ${deliveryId}\` after the server is healthy; acknowledging the same delivery again is safe`, + }, + ); + } const agentReply = flagValue(args, "--agent-reply"); if (agentReply) { - await postJson(`${baseUrl}/api/${sessionKey(absolute)}/agent-reply`, { text: agentReply }); + await postJson(`${baseUrl}/api/${sessionKey(absolute)}/agent-reply`, { + text: agentReply, + ...(deliveryId ? { delivery_id: deliveryId } : {}), + }); } const timeoutMs = flagValue(args, "--timeout-ms"); const timeoutQuery = timeoutMs ? `&timeoutMs=${encodeURIComponent(timeoutMs)}` : ""; @@ -368,7 +387,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 before feedback arrives, re-run \`lavish-axi poll ${file}\` - feedback remains queued until delivery. Poll delivery consumes the response, so read it completely.` + `If it gets killed or times out, re-run \`lavish-axi poll ${file}\` - unacknowledged feedback is released for redelivery when its lease expires.` ); } @@ -380,7 +399,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; feedback remains queued until delivery. Poll delivery consumes the response, so read it completely.` + `re-run \`lavish-axi poll ${file}\` to keep waiting; unacknowledged feedback is released for redelivery when its lease expires.` ); } @@ -408,6 +427,7 @@ export function startPollWaitReporter({ * session: { file: string, status: string, session_ended?: boolean, ended_by?: string }, * prompts?: any[], * artifact_failures?: any[], + * delivery_id?: string, * next_step?: string, * dom_snapshot?: string, * }} @@ -429,8 +449,17 @@ export function createPollOutput({ file, response, agent = "generic" }) { ...(sessionEnded ? { session_ended: true, ...(endedBy ? { ended_by: endedBy } : {}) } : {}), }, prompts: response.prompts || [], + ...(response.delivery_id ? { delivery_id: response.delivery_id } : {}), ...(artifactFailures.length > 0 ? { artifact_failures: artifactFailures } : {}), - next_step: createFeedbackNextStep(file, artifactFailures, sessionEnded, endedBy, response.prompts || [], agent), + next_step: createFeedbackNextStep( + file, + artifactFailures, + sessionEnded, + endedBy, + response.prompts || [], + response.delivery_id, + agent, + ), dom_snapshot: response.dom_snapshot || "", }; } @@ -442,11 +471,19 @@ 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 - feedback remains queued until delivery, so re-running the poll is safe while waiting. Poll delivery consumes the response, so read it completely.`, + next_step: `No user feedback arrived before the optional timeout. Run \`lavish-axi poll ${file}\` without --timeout-ms to wait indefinitely. Unacknowledged feedback is released for redelivery when its lease expires, so re-running the poll is safe.`, }; } -function createFeedbackNextStep(file, artifactFailures, sessionEnded, endedBy, prompts = [], agent = "generic") { +function createFeedbackNextStep( + file, + artifactFailures, + sessionEnded, + endedBy, + prompts = [], + deliveryId = "", + agent = "generic", +) { const count = artifactFailures.length; const whiteboardNote = prompts.some((prompt) => prompt && prompt.tag === "whiteboard") ? `This feedback includes whiteboard edits (tag "whiteboard"): read the edit summary in the prompt text first, and only when it is not enough, open the target's scenePath (.excalidraw scene JSON) or previewPath (PNG) local files for detail. The artifact's Mermaid source stays authoritative - apply the edits by updating the Mermaid text in ${file} (Lavish live-reloads it); never try to write the .excalidraw scene back. ` @@ -469,13 +506,18 @@ function createFeedbackNextStep(file, artifactFailures, sessionEnded, endedBy, p count > 0 ? "" : ` Only run \`lavish-axi ${file} --reopen\` if the user explicitly asks for further review or something genuinely important needs their visual attention.`; - return `${failureNote}${layoutNote}${whiteboardNote}${attachmentNote}This was the last feedback before the user ended the session. Stop polling ${file} and do not reopen it - deliver any remaining updates directly in this conversation instead.${reopenNote}`; + return deliveryId + ? `${failureNote}${layoutNote}${whiteboardNote}${attachmentNote}This was the last feedback before the user ended the session. After applying it, run \`lavish-axi poll ${file} --ack ${deliveryId} --agent-reply ""\`; it acknowledges the final delivery, then reports the ended session. Stop polling and do not reopen it.${reopenNote}` + : `${failureNote}${layoutNote}${whiteboardNote}${attachmentNote}This was the last feedback before the user ended the session. Stop polling ${file} and do not reopen it - deliver any remaining updates directly in this conversation instead.${reopenNote}`; } - return `${failureNote}${layoutNote}${whiteboardNote}${attachmentNote}This was the last feedback before the Lavish Editor session ended. Stop polling ${file}. Deliver any remaining updates directly in this conversation, or run \`lavish-axi ${file}\` to open a fresh session if the user needs further visual review.`; + return deliveryId + ? `${failureNote}${layoutNote}${whiteboardNote}${attachmentNote}This was the last feedback before the Lavish Editor session ended. After applying it, run \`lavish-axi poll ${file} --ack ${deliveryId} --agent-reply ""\`; it acknowledges the final delivery, then reports the ended session. Stop polling afterward.` + : `${failureNote}${layoutNote}${whiteboardNote}${attachmentNote}This was the last feedback before the Lavish Editor session ended. Stop polling ${file}. Deliver any remaining updates directly in this conversation, or run \`lavish-axi ${file}\` to open a fresh session if the user needs further visual review.`; } const prefix = count > 0 ? artifactFailuresPrefix(file, artifactFailures) : `Apply the requested changes to ${file}. `; - return `${prefix}${layoutNote}${whiteboardNote}${attachmentNote}Do not respond to the user just yet. Now you must run \`lavish-axi poll ${file} --agent-reply ""\` without --timeout-ms unless the user ended the session. The poll waits silently until the user sends more feedback or ends the session - never kill it. ${pollExecutionGuidance({ agent })}`; + const acknowledgeFlag = deliveryId ? ` --ack ${deliveryId}` : ""; + return `${prefix}${layoutNote}${whiteboardNote}${attachmentNote}Do not respond to the user just yet. Now you must run \`lavish-axi poll ${file}${acknowledgeFlag} --agent-reply ""\` without --timeout-ms unless the user ended the session. The poll waits silently until the user sends more feedback or ends the session - never kill it. ${pollExecutionGuidance({ agent })}`; } // The narrow fatal path. Ordinary layout findings never reach the poll: they wait in the user's @@ -1751,7 +1793,12 @@ export async function fetchJson(url, { retries = 0, retryDelayMs = 250 } = {}) { } } -async function postJson(url, body) { +/** + * @param {string} url + * @param {unknown} body + * @param {{ connectionFailureSuggestion?: string }} [options] + */ +async function postJson(url, body, { connectionFailureSuggestion } = {}) { let response; try { response = await fetch(url, { @@ -1760,18 +1807,31 @@ async function postJson(url, body) { body: JSON.stringify(body), }); } catch { - throw serverConnectionError(); + throw serverConnectionError(connectionFailureSuggestion); } if (!response.ok) { + let errorBody; + try { + errorBody = await response.json(); + } catch { + // A non-JSON server error still receives the generic status error below. + } + if (errorBody?.code === "STALE_DELIVERY_ID") { + throw new AxiError("--ack delivery_id does not match the pending delivery", "VALIDATION_ERROR", [ + "Re-run `lavish-axi poll ` without --ack to receive the current delivery_id", + ]); + } throw new AxiError(`Lavish Editor request failed: ${response.status}`, "SERVER_ERROR"); } return response.json(); } -function serverConnectionError() { +function serverConnectionError( + retrySuggestion = "Re-run the last `lavish-axi poll ` command after the server is healthy", +) { return new AxiError("Lavish Editor server connection failed", "SERVER_ERROR", [ "Run `lavish-axi server --verbose` or inspect `~/.lavish-axi/server.log` (`LAVISH_AXI_STATE_DIR/server.log` when set) for server startup or crash diagnostics", - "Re-run the last `lavish-axi poll ` command after the server is healthy", + retrySuggestion, ]); } @@ -1829,13 +1889,13 @@ export function getCommandHelp(command, { agent = "generic" } = {}) { } function createTopLevelHelp({ agent = "generic" } = {}) { - return `lavish-axi - Lavish Editor AXI\n\nUsage:\n lavish-axi\n lavish-axi [--no-open] [--no-gate] [--reopen]\n lavish-axi poll [--agent-reply "..."]\n lavish-axi end \n lavish-axi export [--out ]\n lavish-axi share [--private | --password ] [--token ]\n lavish-axi share --site --update-key [--private | --password ]\n lavish-axi share --unpublish --site --update-key \n lavish-axi stop\n lavish-axi playbook [playbook_id]\n lavish-axi design\n lavish-axi setup hooks\n lavish-axi setup plugin\n\n${DESIGN_SYSTEM_HINT}\n\nNote: poll long-polls indefinitely by default until the user sends feedback or ends the session, staying silent while it waits - never kill it. Layout issues the browser detects are passive: they collect in the user's Layout issues inbox in the Lavish top bar and reach the agent only when the user selects them and queues the fixes, as an ordinary tag "layout-warnings" prompt. Do not pass --timeout-ms during normal agent use; it is for tests and debugging only. ${pollExecutionGuidance({ agent })} ${POLL_SEND_AND_END_RULE}\n\n`; + return `lavish-axi - Lavish Editor AXI\n\nUsage:\n lavish-axi\n lavish-axi [--no-open] [--no-gate] [--reopen]\n lavish-axi poll [--ack ] [--agent-reply "..."]\n lavish-axi end \n lavish-axi export [--out ]\n lavish-axi share [--private | --password ] [--token ]\n lavish-axi share --site --update-key [--private | --password ]\n lavish-axi share --unpublish --site --update-key \n lavish-axi stop\n lavish-axi playbook [playbook_id]\n lavish-axi design\n lavish-axi setup hooks\n lavish-axi setup plugin\n\n${DESIGN_SYSTEM_HINT}\n\nNote: poll long-polls indefinitely by default until the user sends feedback or ends the session, staying silent while it waits - never kill it. Feedback is leased under a stable delivery_id and released for redelivery until acknowledged with --ack. Layout issues the browser detects are passive: they collect in the user's Layout issues inbox in the Lavish top bar and reach the agent only when the user selects them and queues the fixes, as an ordinary tag "layout-warnings" prompt. Do not pass --timeout-ms during normal agent use; it is for tests and debugging only. ${pollExecutionGuidance({ agent })} ${POLL_SEND_AND_END_RULE}\n\n`; } function createCommandHelp({ agent = "generic" } = {}) { return { open: `Usage: lavish-axi [--no-open] [--no-gate] [--reopen]\n\nOpen or resume a Lavish Editor review session for an HTML artifact. Use --no-open when you need to ensure the server/session exists without opening another browser window. Use --no-gate to skip the open-time layout curtain for this browser open. If the user explicitly ended the session from the browser, this refuses to reopen it and returns guidance instead - pass --reopen to force it open when the user asks for further review or something important needs their visual attention. Sessions ended by the agent (\`lavish-axi end\`) reopen normally without the flag.\n`, - poll: `Usage: lavish-axi poll [--agent-reply "..."]\n\nThis command long-polls indefinitely for queued user prompts. It stays silent while it waits - that is normal, never kill it. Browser-detected layout issues do NOT return this poll: they are filed passively in the user's Layout issues inbox and arrive as an ordinary tag "layout-warnings" prompt only after the user selects them and queues the fixes. Warning lifecycle: an issue stays unresolved and counted while queued, becomes recurring if a newer artifact revision still shows it, and is resolved only after a newer artifact load plus a complete diagnostic pass at the same viewport no longer detects it. A failed or incomplete pass preserves it as unverified rather than clearing it. The only response that arrives without user action is artifact_failures - a fatal failure that made the review surface itself unusable. Do not pass --timeout-ms during normal agent use; it is for tests and debugging only. ${pollExecutionGuidance({ agent })} Use --agent-reply after applying prior feedback to display your response in Lavish Editor before waiting again. ${POLL_SEND_AND_END_RULE}\n`, + poll: `Usage: lavish-axi poll [--ack ] [--agent-reply "..."]\n\nThis command long-polls indefinitely for queued user prompts. Feedback is leased under a stable delivery_id and released for redelivery until you process it and pass --ack on the next poll. It stays silent while it waits - that is normal, never kill it. Browser-detected layout issues do NOT return this poll: they are filed passively in the user's Layout issues inbox and arrive as an ordinary tag "layout-warnings" prompt only after the user selects them and queues the fixes. Warning lifecycle: an issue stays unresolved and counted while queued, becomes recurring if a newer artifact revision still shows it, and is resolved only after a newer artifact load plus a complete diagnostic pass at the same viewport no longer detects it. A failed or incomplete pass preserves it as unverified rather than clearing it. The only response that arrives without user action is artifact_failures - a fatal failure that made the review surface itself unusable. Do not pass --timeout-ms during normal agent use; it is for tests and debugging only. ${pollExecutionGuidance({ agent })} Use --agent-reply after applying prior feedback to display your response in Lavish Editor before waiting again. ${POLL_SEND_AND_END_RULE}\n`, end: `Usage: lavish-axi end \n\nEnd a Lavish Editor session as the agent. A session ended this way still reopens normally on the next \`lavish-axi \`, unlike a user ending it from the browser, which requires --reopen.\n`, export: `Usage: lavish-axi export [--out ]\n\nWrite a portable copy of an artifact: one HTML file with its LOCAL assets inlined (relative-path stylesheets, scripts, images, and fonts become inline