lavish-axi 0.1.73 → 0.1.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -205,7 +205,8 @@ pnpm link
205
205
  - **Keyboard shortcuts** - In the chrome composer, Enter sends queued prompts and Shift+Enter inserts a newline.
206
206
  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.
207
207
  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.
208
- - **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. An agent reply concludes delivered work and returns presence to waiting.
208
+ - **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. An agent reply concludes delivered work and returns presence to waiting. A supervisor-owned process listener is shown as an external listener rather than an idle captain turn.
209
+ In a Herdr-managed pane, set `LAVISH_AXI_HERDR_CHIME=1` to show a Herdr request notification and play its chime once an indefinite poll enters the waiting state. Immediate queued feedback and timed debug polls do not trigger a notification. The integration is off unless both `LAVISH_AXI_HERDR_CHIME` and Herdr's `HERDR_ENV` session marker equal `1`; leaving either unset or using any other value opts out. Notification delivery runs without blocking feedback: if Herdr is unavailable, rejects the notification, stalls, or fails, the poll continues unchanged.
209
210
  Closing the last review tab while a poll is active starts a 10-second reconnect grace period. A reload or quick reconnect keeps an active poll waiting; if every review stays disconnected, the poll returns `browser_disconnected` without ending the resumable session, and the agent asks whether to reopen or end it instead of acting uninvited.
210
211
  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.
211
212
  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.
@@ -285,6 +286,8 @@ For flows, architecture, state, or sequence diagrams, open the diagram playbook
285
286
  | `lavish-axi share` | `--update-key <key>` | The secret returned when the page was published; required to republish or unpublish it. |
286
287
  | `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. |
287
288
  | `lavish-axi share` | `--token <t>` | 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. |
289
+ | `lavish-axi poll` | `--owner <label>` | Identify the process listening for this session; the non-empty label appears in the home session listing. `none` is reserved for sessions without a listener. A second concurrent poll is refused instead of silently returning `waiting`. |
290
+ | `lavish-axi poll` | `--takeover` | Explicitly displace the current poll listener. The displaced poll exits with typed `LISTENER_REPLACED`; without this flag, the new poll fails with typed `LISTENER_ACTIVE`. |
288
291
  | `lavish-axi poll` | `--agent-reply "..."` | Show a concise agent reply in the existing browser chat, conclude delivered work, and return presence to waiting before polling again. Keep replies concise by default. |
289
292
  | `lavish-axi poll` | `--agent-reply-file <path>` | When a longer reply is genuinely necessary, read Markdown from a file (`-` for stdin) so newlines survive quoting. Use blank-line paragraphs, `- ` / `1. ` lists, and `## ` headings so the Conversation panel renders it scannably. The Markdown subset is under Feedback controls. Cannot be combined with `--agent-reply`. |
290
293
  | `lavish-axi poll` | `--timeout-ms <ms>` | Test/debug escape hatch only; agents should normally omit it and leave the long poll running. |
@@ -988,12 +988,14 @@ function syncChat(chat, revision) {
988
988
  }
989
989
 
990
990
  function setAgentPresence(state) {
991
- agentPresence = state === "listening" || state === "working" ? state : "waiting";
991
+ agentPresence = state === "listening" || state === "external" || state === "working" ? state : "waiting";
992
992
  updateSendState();
993
993
  renderSheetSummary();
994
994
  if (presenceBanner) presenceBanner.hidden = ended || agentPresence !== "waiting";
995
995
 
996
- if (agentPresence !== "working") {
996
+ // A supervisor-owned process-only listener is busy on the agent's behalf. It must not make the
997
+ // composer look like an idle captain turn while the owning agent is offline.
998
+ if (agentPresence !== "working" && agentPresence !== "external") {
997
999
  if (workingBubble) workingBubble.remove();
998
1000
  workingBubble = null;
999
1001
  return;
@@ -1209,6 +1211,7 @@ function sheetSummary() {
1209
1211
  }
1210
1212
  if (unreadAgentReply) return { text: unreadAgentReply, accent: false, unread: true };
1211
1213
  if (agentPresence === "working") return { text: "Agent is working…", accent: false, unread: false };
1214
+ if (agentPresence === "external") return { text: "External listener active", accent: false, unread: false };
1212
1215
  if (agentPresence === "listening") return { text: "Agent listening", accent: false, unread: false };
1213
1216
  return { text: "Agent not listening", accent: false, unread: false };
1214
1217
  }
@@ -4075,7 +4078,7 @@ events.set("chat-sync", (data) => {
4075
4078
  rememberChatAckIds(data.ack_ids);
4076
4079
  syncChat(data.chat || [], data.chat_revision);
4077
4080
  });
4078
- events.set("agent-presence", (data) => setAgentPresence(data.state));
4081
+ events.set("agent-presence", (data) => setAgentPresence(data.mode === "external-listener" ? "external" : data.state));
4079
4082
  events.set("layout-warnings", (data) => setLayoutWarnings(data.warnings || []));
4080
4083
  events.set("ended", () => markSessionEnded());
4081
4084
  connectLiveEvents();
package/dist/cli.mjs CHANGED
@@ -9392,6 +9392,7 @@ var BROWSER_DISCONNECT_GRACE_MS = 1e4;
9392
9392
  var ARTIFACT_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads";
9393
9393
  var ATTACHMENT_SWEEP_INTERVAL_MS = 60 * 6e4;
9394
9394
  var SHUTDOWN_REASONS = /* @__PURE__ */ new Set(["upgrade", "local-build", "stop"]);
9395
+ var AGENT_LISTENER_LABEL = "agent-listener";
9395
9396
  var RELOAD_DEBOUNCE_MS = 100;
9396
9397
  var BATCH_RELOAD_DEBOUNCE_MS = 900;
9397
9398
  function defaultWhiteboardAssetsDir() {
@@ -9520,8 +9521,10 @@ async function serve({
9520
9521
  const watchers = /* @__PURE__ */ new Map();
9521
9522
  const activePolls = /* @__PURE__ */ new Map();
9522
9523
  const deliveredFeedback = /* @__PURE__ */ new Set();
9524
+ const pollOwnershipLock = new AsyncMutex();
9523
9525
  const liveEventClients = /* @__PURE__ */ new Map();
9524
9526
  const browserDisconnectTimers = /* @__PURE__ */ new Map();
9527
+ const browserDisconnectPending = /* @__PURE__ */ new Set();
9525
9528
  const whiteboardChannelSecret = crypto6.randomBytes(32);
9526
9529
  const outstandingRepairBatches = /* @__PURE__ */ new Set();
9527
9530
  const diagnosticViewportClasses = resolveDiagnosticViewportClasses();
@@ -9544,7 +9547,10 @@ async function serve({
9544
9547
  events.on("reload", (key) => broadcastLiveEvent("reload", key));
9545
9548
  events.on("agent-reply", (key, entry) => broadcastLiveEvent("agent-reply", key, entry));
9546
9549
  events.on("chat-sync", (key, session) => broadcastLiveEvent("chat-sync", key, serializeChatSync(session)));
9547
- events.on("agent-presence", (key, state) => broadcastLiveEvent("agent-presence", key, { state }));
9550
+ events.on(
9551
+ "agent-presence",
9552
+ (key, state) => broadcastLiveEvent("agent-presence", key, presenceEventData(key, state, activePolls, deliveredFeedback))
9553
+ );
9548
9554
  events.on("layout-warnings", (key, warnings) => broadcastLiveEvent("layout-warnings", key, { warnings }));
9549
9555
  events.on("ended", (key, endedBy) => broadcastLiveEvent("ended", key, { ended_by: endedBy || null }));
9550
9556
  function hasLiveEventClient(key) {
@@ -9564,13 +9570,17 @@ async function serve({
9564
9570
  if (shuttingDown || hasLiveEventClient(key) || !activePolls.has(key)) return;
9565
9571
  const timer = setTimeout(() => {
9566
9572
  browserDisconnectTimers.delete(key);
9567
- if (!hasLiveEventClient(key) && activePolls.has(key)) events.emit("browser-disconnected", key);
9573
+ if (!hasLiveEventClient(key) && activePolls.has(key)) {
9574
+ browserDisconnectPending.add(key);
9575
+ events.emit("browser-disconnected", key);
9576
+ }
9568
9577
  }, browserDisconnectGraceMs);
9569
9578
  timer.unref?.();
9570
9579
  browserDisconnectTimers.set(key, timer);
9571
9580
  }
9572
9581
  function attachLiveEventClient(client, key, onClose) {
9573
9582
  clearBrowserDisconnectTimer(key);
9583
+ browserDisconnectPending.delete(key);
9574
9584
  liveEventClients.set(client, key);
9575
9585
  refreshIdleTimer();
9576
9586
  let cleanedUp = false;
@@ -9591,7 +9601,8 @@ async function serve({
9591
9601
  return;
9592
9602
  }
9593
9603
  client.sendEvent("chat-sync", serializeChatSync(session));
9594
- client.sendEvent("agent-presence", { state: computePresence(key, activePolls, deliveredFeedback) });
9604
+ const presence = computePresence(key, activePolls, deliveredFeedback);
9605
+ client.sendEvent("agent-presence", presenceEventData(key, presence, activePolls, deliveredFeedback));
9595
9606
  if (session?.status === "ended") client.sendEvent("ended", { ended_by: session.ended_by || null });
9596
9607
  }
9597
9608
  function requestedBindIsRecoverable() {
@@ -9627,7 +9638,6 @@ async function serve({
9627
9638
  function finishFeedbackDelivery(key, result) {
9628
9639
  if (result.status !== "feedback") return;
9629
9640
  markFeedbackDelivered(key, activePolls, deliveredFeedback, events);
9630
- if (result.session_ended) clearFeedbackDelivery(key, activePolls, deliveredFeedback, events);
9631
9641
  }
9632
9642
  async function restoreClosedFeedback(key, result) {
9633
9643
  if (result.status !== "feedback") return;
@@ -9754,7 +9764,11 @@ async function serve({
9754
9764
  app: "lavish-axi",
9755
9765
  version,
9756
9766
  ...networkStale ? { network_stale: true } : {},
9757
- ...networkWarning ? { network_warning: networkWarning } : {}
9767
+ ...networkWarning ? { network_warning: networkWarning } : {},
9768
+ listeners: [...activePolls].map(([key, holder]) => ({
9769
+ key,
9770
+ label: listenerLabel(holder)
9771
+ }))
9758
9772
  });
9759
9773
  });
9760
9774
  let shutdownResolve;
@@ -9804,13 +9818,44 @@ async function serve({
9804
9818
  next(error);
9805
9819
  }
9806
9820
  });
9807
- app.get("/api/poll", async (req, res, next) => {
9808
- let requestClosed = Boolean(req.destroyed);
9821
+ async function publishAgentReply(key, text) {
9822
+ const session = await store.addAgentReply(key, text);
9823
+ if (!session) return null;
9824
+ const lastEntry = session.chat?.at(-1);
9825
+ const entry = serializeChat([
9826
+ lastEntry?.role === "agent" ? lastEntry : { role: "agent", text, at: session.updated_at }
9827
+ ])[0];
9828
+ events.emit("agent-reply", key, entry);
9829
+ events.emit("chat-sync", key, session);
9830
+ clearFeedbackDelivery(key, activePolls, deliveredFeedback, events);
9831
+ return session;
9832
+ }
9833
+ const handlePoll = async (req, res, next) => {
9834
+ const takeover = req.query.takeover === "1";
9835
+ const agentReply = req.method === "POST" && req.body?.agent_reply !== void 0 ? String(req.body.agent_reply) : null;
9836
+ if (takeover && req.method !== "POST") {
9837
+ res.status(405).json({ error: "poll takeover requires POST" });
9838
+ return;
9839
+ }
9840
+ if (req.method === "POST" && (agentReply === null ? !takeover : !agentReply.trim())) {
9841
+ res.status(405).json({ error: "POST /api/poll requires agent_reply or takeover=1" });
9842
+ return;
9843
+ }
9844
+ let requestClosed = Boolean(req.aborted || req.method !== "POST" && req.destroyed);
9809
9845
  let cleanupPoll = null;
9846
+ let claimedHolder = null;
9810
9847
  const onRequestClose = () => {
9848
+ if (req.method === "POST" && !req.aborted) return;
9811
9849
  requestClosed = true;
9812
9850
  cleanupPoll?.();
9813
9851
  };
9852
+ const onResponseClose = () => {
9853
+ if (req.method === "POST" && !res.writableEnded) {
9854
+ requestClosed = true;
9855
+ cleanupPoll?.();
9856
+ }
9857
+ };
9858
+ res.on("close", onResponseClose);
9814
9859
  const detachRequestClose = () => req.off("close", onRequestClose);
9815
9860
  req.on("close", onRequestClose);
9816
9861
  try {
@@ -9824,34 +9869,99 @@ async function serve({
9824
9869
  };
9825
9870
  const file = await canonicalFile(String(req.query.file || ""));
9826
9871
  const key = sessionKey(file);
9872
+ const ownerValue = typeof req.query.owner === "string" ? req.query.owner.trim() : "";
9873
+ if (ownerValue.toLowerCase() === "none" || ownerValue.startsWith("-")) {
9874
+ detachRequestClose();
9875
+ res.status(400).json({
9876
+ status: "error",
9877
+ code: "VALIDATION_ERROR",
9878
+ error: "--owner none is reserved; pass a different listener label"
9879
+ });
9880
+ return;
9881
+ }
9882
+ const owner = ownerValue || null;
9883
+ if (hasPresentOriginOrReferer(req) && !isSameOriginRequest(req, allowedHostnames, allowAnyHostname)) {
9884
+ detachRequestClose();
9885
+ res.status(403).json({ error: "cross-origin poll takeover rejected" });
9886
+ return;
9887
+ }
9888
+ const { holder, previousPresence, conflict, missing } = await pollOwnershipLock.runExclusive(async () => {
9889
+ const currentHolder = activePolls.get(key);
9890
+ if (currentHolder && !takeover) return { conflict: listenerConflict(currentHolder, "LISTENER_ACTIVE") };
9891
+ const nextHolder = { key, owner, startedAt: Date.now(), replaced: false, replace: null };
9892
+ claimedHolder = nextHolder;
9893
+ const priorPresence = presenceSignature(key, activePolls, deliveredFeedback);
9894
+ if (currentHolder) {
9895
+ currentHolder.replaced = true;
9896
+ activePolls.set(key, nextHolder);
9897
+ currentHolder.replace?.();
9898
+ } else {
9899
+ activePolls.set(key, nextHolder);
9900
+ }
9901
+ deliveredFeedback.delete(key);
9902
+ if (agentReply !== null) {
9903
+ const session = await publishAgentReply(key, agentReply);
9904
+ if (!session) {
9905
+ activePolls.delete(key);
9906
+ return { missing: true };
9907
+ }
9908
+ }
9909
+ return { holder: nextHolder, previousPresence: priorPresence };
9910
+ });
9911
+ if (conflict) {
9912
+ detachRequestClose();
9913
+ res.status(409).json(conflict);
9914
+ return;
9915
+ }
9916
+ if (missing) {
9917
+ detachRequestClose();
9918
+ res.status(404).json({ error: "session not found" });
9919
+ return;
9920
+ }
9827
9921
  const timeoutMs = req.query.timeoutMs === void 0 ? null : Math.max(0, Math.min(Number(req.query.timeoutMs || 0), 2147483647));
9828
9922
  const immediate = await store.takeFeedback(key);
9829
9923
  if (immediate.status !== "waiting") {
9830
- if (requestClosed || req.destroyed || res.writableEnded) {
9924
+ if (holder.replaced) {
9925
+ await restoreClosedFeedback(key, immediate);
9926
+ detachRequestClose();
9927
+ res.json(listenerConflict(holder, "LISTENER_REPLACED"));
9928
+ releasePollListener(holder, activePolls, deliveredFeedback, events);
9929
+ return;
9930
+ }
9931
+ if (requestClosed || res.writableEnded) {
9831
9932
  await restoreClosedFeedback(key, immediate);
9933
+ releasePollListener(holder, activePolls, deliveredFeedback, events);
9832
9934
  detachRequestClose();
9833
9935
  return;
9834
9936
  }
9835
9937
  finishFeedbackDelivery(key, immediate);
9938
+ releasePollListener(holder, activePolls, deliveredFeedback, events);
9939
+ if (immediate.session_ended) clearFeedbackDelivery(key, activePolls, deliveredFeedback, events);
9836
9940
  detachRequestClose();
9837
9941
  res.json(immediate);
9838
9942
  return;
9839
9943
  }
9840
- if (requestClosed || req.destroyed || res.writableEnded) {
9944
+ if (holder.replaced) {
9945
+ detachRequestClose();
9946
+ res.json(listenerConflict(holder, "LISTENER_REPLACED"));
9947
+ releasePollListener(holder, activePolls, deliveredFeedback, events);
9948
+ return;
9949
+ }
9950
+ if (requestClosed || res.writableEnded) {
9951
+ releasePollListener(holder, activePolls, deliveredFeedback, events);
9841
9952
  detachRequestClose();
9842
9953
  return;
9843
9954
  }
9844
9955
  const streamHeartbeat = timeoutMs === null;
9845
9956
  let heartbeat = null;
9846
9957
  if (streamHeartbeat) {
9847
- res.status(200).type("application/json");
9958
+ res.status(200).type("application/json").set("Lavish-Poll-State", "listening");
9848
9959
  res.write(" ");
9849
9960
  heartbeat = setInterval(() => {
9850
9961
  if (!res.writableEnded) res.write(" ");
9851
9962
  }, pollHeartbeatMs);
9852
9963
  heartbeat.unref?.();
9853
9964
  }
9854
- setPollActive(key, activePolls, deliveredFeedback, events, true);
9855
9965
  refreshIdleTimer();
9856
9966
  let timer = null;
9857
9967
  let cleaned = false;
@@ -9864,19 +9974,45 @@ async function serve({
9864
9974
  events.off("feedback", onFeedback);
9865
9975
  events.off("ended", onFeedback);
9866
9976
  events.off("browser-disconnected", onBrowserDisconnected);
9867
- setPollActive(key, activePolls, deliveredFeedback, events, false);
9868
- if (!activePolls.has(key)) clearBrowserDisconnectTimer(key);
9977
+ releasePollListener(holder, activePolls, deliveredFeedback, events);
9978
+ if (!activePolls.has(key)) {
9979
+ clearBrowserDisconnectTimer(key);
9980
+ browserDisconnectPending.delete(key);
9981
+ }
9869
9982
  refreshIdleTimer();
9870
9983
  cleanupPoll = null;
9871
9984
  detachRequestClose();
9985
+ res.off("close", onResponseClose);
9872
9986
  };
9987
+ const respondReplacement = () => {
9988
+ if (responding || res.writableEnded) return;
9989
+ responding = true;
9990
+ try {
9991
+ const replacement = listenerConflict(holder, "LISTENER_REPLACED");
9992
+ if (streamHeartbeat) res.end(JSON.stringify(replacement));
9993
+ else res.json(replacement);
9994
+ } finally {
9995
+ cleanup();
9996
+ }
9997
+ };
9998
+ holder.replace = respondReplacement;
9873
9999
  const respond = async (forcedResult = null) => {
9874
10000
  if (responding || res.writableEnded) return;
9875
10001
  responding = true;
10002
+ let finalSessionEnded = false;
9876
10003
  try {
9877
10004
  const result = await store.takeFeedback(key);
9878
10005
  const responseResult = forcedResult && result.status === "waiting" ? forcedResult : result;
9879
- if (requestClosed || req.destroyed || res.writableEnded) {
10006
+ finalSessionEnded = responseResult?.session_ended === true;
10007
+ if (holder.replaced) {
10008
+ if (responseResult.status !== "waiting") await restoreClosedFeedback(key, responseResult);
10009
+ if (!requestClosed && !res.writableEnded) {
10010
+ if (streamHeartbeat) res.end(JSON.stringify(listenerConflict(holder, "LISTENER_REPLACED")));
10011
+ else res.json(listenerConflict(holder, "LISTENER_REPLACED"));
10012
+ }
10013
+ return;
10014
+ }
10015
+ if (requestClosed || res.writableEnded) {
9880
10016
  await restoreClosedFeedback(key, responseResult);
9881
10017
  return;
9882
10018
  }
@@ -9888,6 +10024,7 @@ async function serve({
9888
10024
  }
9889
10025
  } finally {
9890
10026
  cleanup();
10027
+ if (finalSessionEnded) clearFeedbackDelivery(key, activePolls, deliveredFeedback, events);
9891
10028
  }
9892
10029
  };
9893
10030
  const onFeedback = (changedKey) => {
@@ -9904,17 +10041,33 @@ async function serve({
9904
10041
  events.on("ended", onFeedback);
9905
10042
  events.on("browser-disconnected", onBrowserDisconnected);
9906
10043
  cleanupPoll = cleanup;
9907
- if (requestClosed || req.destroyed || res.writableEnded) {
10044
+ if (holder.replaced) {
10045
+ respondReplacement();
10046
+ return;
10047
+ }
10048
+ if (requestClosed || res.writableEnded) {
9908
10049
  cleanup();
9909
10050
  return;
9910
10051
  }
10052
+ if (browserDisconnectPending.has(key)) {
10053
+ browserDisconnectPending.delete(key);
10054
+ onBrowserDisconnected(key);
10055
+ return;
10056
+ }
10057
+ const nextPresence = presenceSignature(key, activePolls, deliveredFeedback);
10058
+ if (nextPresence !== previousPresence) {
10059
+ events.emit("agent-presence", key, computePresence(key, activePolls, deliveredFeedback));
10060
+ }
9911
10061
  timer = timeoutMs === null ? null : setTimeout(() => respond().catch(handleRespondError), timeoutMs);
9912
10062
  } catch (error) {
9913
10063
  cleanupPoll?.();
10064
+ if (claimedHolder) releasePollListener(claimedHolder, activePolls, deliveredFeedback, events);
9914
10065
  detachRequestClose();
9915
10066
  next(error);
9916
10067
  }
9917
- });
10068
+ };
10069
+ app.get("/api/poll", handlePoll);
10070
+ app.post("/api/poll", handlePoll);
9918
10071
  app.post("/api/:key/prompts", async (req, res, next) => {
9919
10072
  try {
9920
10073
  if (!isSameOriginRequest(req, allowedHostnames, allowAnyHostname)) {
@@ -10066,18 +10219,11 @@ async function serve({
10066
10219
  });
10067
10220
  app.post("/api/:key/agent-reply", async (req, res, next) => {
10068
10221
  try {
10069
- const text = String(req.body?.text || "");
10070
- const session = await store.addAgentReply(req.params.key, text);
10222
+ const session = await publishAgentReply(req.params.key, String(req.body?.text || ""));
10071
10223
  if (!session) {
10072
10224
  res.status(404).json({ error: "session not found" });
10073
10225
  return;
10074
10226
  }
10075
- const entry = serializeChat([
10076
- session.chat?.at(-1)?.role === "agent" ? session.chat.at(-1) : { role: "agent", text, at: session.updated_at }
10077
- ])[0];
10078
- events.emit("agent-reply", req.params.key, entry);
10079
- events.emit("chat-sync", req.params.key, session);
10080
- clearFeedbackDelivery(req.params.key, activePolls, deliveredFeedback, events);
10081
10227
  res.json({ status: "sent" });
10082
10228
  } catch (error) {
10083
10229
  next(error);
@@ -10658,7 +10804,9 @@ ${body}`
10658
10804
  let retryIndex = 0;
10659
10805
  while (true) {
10660
10806
  try {
10661
- const httpServer = await listenHttp(app, boundPort, listenHost);
10807
+ const httpServer = await listenHttp(app, boundPort, listenHost, (error) => {
10808
+ writeLog(`[lavish] HTTP server error: ${error instanceof Error ? error.message : String(error)}`);
10809
+ });
10662
10810
  httpServer.on("upgrade", handleEventUpgrade);
10663
10811
  if (boundPort === 0) boundPort = httpServer.address().port;
10664
10812
  httpServers.push(httpServer);
@@ -10858,15 +11006,20 @@ ${body}`
10858
11006
  done
10859
11007
  };
10860
11008
  }
10861
- function listenHttp(app, port, host) {
11009
+ function listenHttp(app, port, host, onRuntimeError) {
10862
11010
  return new Promise((resolve, reject) => {
10863
11011
  const server = createServer(app);
11012
+ let listening = false;
10864
11013
  const onError = (error) => {
10865
- server.off("listening", onListening);
10866
- reject(error);
11014
+ if (!listening) {
11015
+ server.off("listening", onListening);
11016
+ reject(error);
11017
+ return;
11018
+ }
11019
+ onRuntimeError?.(error);
10867
11020
  };
10868
11021
  const onListening = () => {
10869
- server.off("error", onError);
11022
+ listening = true;
10870
11023
  const address = server.address();
10871
11024
  if (address && typeof address === "object" && isWildcardHost(address.address)) {
10872
11025
  server.close(() => reject(new Error(`Refusing all-interfaces listener at ${address.address}`)));
@@ -10874,7 +11027,7 @@ function listenHttp(app, port, host) {
10874
11027
  }
10875
11028
  resolve(server);
10876
11029
  };
10877
- server.once("error", onError);
11030
+ server.on("error", onError);
10878
11031
  server.once("listening", onListening);
10879
11032
  server.listen({ port, host });
10880
11033
  });
@@ -11100,19 +11253,23 @@ function hasLiveReloadRootOptIn(html) {
11100
11253
  if (/<html\b[^>]*\sdata-lavish-live-reload-root(?:[\s=>/]|$)[^>]*>/i.test(searchableHtml)) return true;
11101
11254
  return /<meta\b(?=[^>]*name=["']lavish-live-reload["'])(?=[^>]*content=["']root["'])[^>]*>/i.test(searchableHtml);
11102
11255
  }
11103
- function setPollActive(key, activePolls, deliveredFeedback, events, active) {
11104
- const previousPresence = computePresence(key, activePolls, deliveredFeedback);
11105
- const count = activePolls.get(key) || 0;
11106
- const nextCount = active ? count + 1 : Math.max(0, count - 1);
11107
- if (nextCount === count) return;
11108
- if (nextCount === 0) {
11109
- activePolls.delete(key);
11110
- } else {
11111
- activePolls.set(key, nextCount);
11112
- }
11113
- if (active && count === 0) deliveredFeedback.delete(key);
11114
- const nextPresence = computePresence(key, activePolls, deliveredFeedback);
11115
- if (nextPresence !== previousPresence) events.emit("agent-presence", key, nextPresence);
11256
+ function releasePollListener(holder, activePolls, deliveredFeedback, events) {
11257
+ if (activePolls.get(holder.key) !== holder) return;
11258
+ const previousPresence = computePresence(holder.key, activePolls, deliveredFeedback);
11259
+ activePolls.delete(holder.key);
11260
+ const nextPresence = computePresence(holder.key, activePolls, deliveredFeedback);
11261
+ if (nextPresence !== previousPresence) events.emit("agent-presence", holder.key, nextPresence);
11262
+ }
11263
+ function listenerLabel(holder) {
11264
+ return holder.owner || AGENT_LISTENER_LABEL;
11265
+ }
11266
+ function listenerConflict(holder, code) {
11267
+ return {
11268
+ status: "error",
11269
+ code,
11270
+ error: code === "LISTENER_ACTIVE" ? "Lavish Editor already has an active poll listener" : "Lavish Editor poll listener was replaced",
11271
+ holder: { label: listenerLabel(holder), age_ms: Math.max(0, Date.now() - holder.startedAt) }
11272
+ };
11116
11273
  }
11117
11274
  function markFeedbackDelivered(key, activePolls, deliveredFeedback, events) {
11118
11275
  const previousPresence = computePresence(key, activePolls, deliveredFeedback);
@@ -11131,9 +11288,21 @@ function clearFeedbackDelivery(key, activePolls, deliveredFeedback, events) {
11131
11288
  }
11132
11289
  }
11133
11290
  function computePresence(key, activePolls, deliveredFeedback) {
11134
- if (activePolls.has(key)) return "listening";
11135
11291
  if (deliveredFeedback.has(key)) return "working";
11136
- return "waiting";
11292
+ return activePolls.has(key) ? "listening" : "waiting";
11293
+ }
11294
+ function presenceSignature(key, activePolls, deliveredFeedback) {
11295
+ return `${computePresence(key, activePolls, deliveredFeedback)}:${presenceMode(key, activePolls, deliveredFeedback)}`;
11296
+ }
11297
+ function presenceMode(key, activePolls, deliveredFeedback) {
11298
+ if (deliveredFeedback.has(key)) return "agent-busy";
11299
+ const holder = activePolls.get(key);
11300
+ if (!holder) return "waiting-on-captain";
11301
+ return holder.owner ? "external-listener" : "agent-listener";
11302
+ }
11303
+ function presenceEventData(key, state, activePolls, deliveredFeedback) {
11304
+ const mode = presenceMode(key, activePolls, deliveredFeedback);
11305
+ return mode === "waiting-on-captain" ? { state } : { state, mode };
11137
11306
  }
11138
11307
  function chromeIcon(paths, size = 16, strokeWidth = 1.7) {
11139
11308
  return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
@@ -11614,19 +11783,62 @@ var POLL_SEND_AND_END_RULE = "`Send & End` ends the session. Its final feedback
11614
11783
  var POLL_AGENT_REPLY_RULE = "Keep the reply concise. Only when a longer reply is genuinely necessary, use Markdown structure - blank-line paragraphs, `- ` / `1. ` lists, `## ` headings - so it renders scannably instead of a wall of text, and pass that body with `--agent-reply-file <path>` (`-` reads stdin) so newlines survive quoting.";
11615
11784
  var POLL_AGENT_REPLY_HELP_POINTER = "The Conversation panel's Markdown subset is in README's Feedback controls bullet.";
11616
11785
  var POLL_AGENT_REPLY_NEXT_POINTER = "The Conversation panel's Markdown subset is in `lavish-axi poll --help` and README.";
11617
- var POLL_VALUE_FLAGS = ["--agent-reply", "--agent-reply-file", "--timeout-ms"];
11786
+ var POLL_VALUE_FLAGS = ["--agent-reply", "--agent-reply-file", "--timeout-ms", "--owner"];
11618
11787
  var AGENT_REPLY_JSON_LIMIT_BYTES = 2 * 1024 * 1024;
11619
- var AGENT_REPLY_JSON_ENVELOPE_BYTES = Buffer.byteLength(JSON.stringify({ text: "" }));
11788
+ var AGENT_REPLY_JSON_ENVELOPE_BYTES = Buffer.byteLength(JSON.stringify({ agent_reply: "" }));
11620
11789
  var AGENT_REPLY_INPUT_LIMIT_BYTES = AGENT_REPLY_JSON_LIMIT_BYTES - AGENT_REPLY_JSON_ENVELOPE_BYTES;
11621
11790
  var AGENT_REPLY_LIMIT_LABEL = "2 MB JSON request limit";
11791
+ var POLL_STATE_HEADER = "lavish-poll-state";
11622
11792
  var CODEX_POLL_WAKE_PATH_GUIDANCE = "Codex detected: completed background tasks may not resume Codex automatically, so keep the poll attached to the active turn.";
11623
- var VERSION = "0.1.73";
11793
+ var VERSION = "0.1.75";
11624
11794
  function detectInvokingAgent(env = process.env) {
11625
11795
  return ["CODEX_SANDBOX", "CODEX_THREAD_ID"].some((key) => Object.hasOwn(env, key)) ? "codex" : "generic";
11626
11796
  }
11627
11797
  function shouldNarratePollWaitTicks({ isTTY }) {
11628
11798
  return Boolean(isTTY);
11629
11799
  }
11800
+ function herdrPollChimeEnabled(env = process.env) {
11801
+ return env.HERDR_ENV === "1" && env.LAVISH_AXI_HERDR_CHIME === "1";
11802
+ }
11803
+ function notifyHerdrPollReady({ env = process.env, runner = spawn } = {}) {
11804
+ if (!herdrPollChimeEnabled(env)) return false;
11805
+ try {
11806
+ const child = runner(
11807
+ "herdr",
11808
+ [
11809
+ "notification",
11810
+ "show",
11811
+ "Lavish review ready",
11812
+ "--body",
11813
+ "The artifact is open and Lavish is polling for your feedback.",
11814
+ "--sound",
11815
+ "request"
11816
+ ],
11817
+ { stdio: "ignore" }
11818
+ );
11819
+ const kill = () => {
11820
+ try {
11821
+ child.kill("SIGKILL");
11822
+ } catch {
11823
+ return;
11824
+ }
11825
+ };
11826
+ const timer = setTimeout(kill, 2e3);
11827
+ timer.unref();
11828
+ const cleanup = () => {
11829
+ clearTimeout(timer);
11830
+ process.off("exit", kill);
11831
+ };
11832
+ child.on("error", () => {
11833
+ });
11834
+ child.once("close", cleanup);
11835
+ process.once("exit", kill);
11836
+ child.unref();
11837
+ return true;
11838
+ } catch {
11839
+ return false;
11840
+ }
11841
+ }
11630
11842
  function pollExecutionGuidance({ agent = "generic" } = {}) {
11631
11843
  const sharedGuidance = POLL_WAKE_PATH_RULES.join(" ");
11632
11844
  const agentGuidance = agent === "codex" ? ` ${CODEX_POLL_WAKE_PATH_GUIDANCE}` : "";
@@ -11721,7 +11933,8 @@ function createHomeOutput({ bin, sessions, includeSessions = true, agent = "gene
11721
11933
  file: session.file,
11722
11934
  status: session.status,
11723
11935
  url: session.url,
11724
- pending_prompts: session.pending_prompts || 0
11936
+ pending_prompts: session.pending_prompts || 0,
11937
+ listener: session.listener || "none"
11725
11938
  }))
11726
11939
  } : {},
11727
11940
  visual_guidance: [
@@ -11841,14 +12054,24 @@ async function pollCommand(args) {
11841
12054
  if (!file) {
11842
12055
  throw new AxiError("HTML file path is required", "VALIDATION_ERROR", ["Run `lavish-axi poll <html-file>`"]);
11843
12056
  }
12057
+ const ownerFlag = inspectValueFlag(args, "--owner");
12058
+ const owner = ownerFlag.present ? String(ownerFlag.value || "").trim() : null;
12059
+ if (ownerFlag.present && (!owner || owner.startsWith("-") || owner.toLowerCase() === "none")) {
12060
+ throw new AxiError(
12061
+ owner?.toLowerCase() === "none" ? "--owner none is reserved" : "--owner requires a non-empty label",
12062
+ "VALIDATION_ERROR",
12063
+ ["Pass `--owner <label>` to identify the process listening for feedback"]
12064
+ );
12065
+ }
12066
+ const takeover = args.includes("--takeover");
11844
12067
  const agentReply = await resolveAgentReply(args);
11845
12068
  const absolute = await canonicalFile(file);
11846
12069
  const baseUrl = await ensureServer();
11847
- if (agentReply) {
11848
- await postJson(`${baseUrl}/api/${sessionKey(absolute)}/agent-reply`, { text: agentReply });
11849
- }
11850
12070
  const timeoutMs = flagValue(args, "--timeout-ms");
11851
- const timeoutQuery = timeoutMs ? `&timeoutMs=${encodeURIComponent(timeoutMs)}` : "";
12071
+ const query = new URLSearchParams({ file: absolute });
12072
+ if (timeoutMs) query.set("timeoutMs", timeoutMs);
12073
+ if (owner) query.set("owner", owner);
12074
+ if (takeover) query.set("takeover", "1");
11852
12075
  const onPollSignal = (signal) => {
11853
12076
  process.stderr.write(`
11854
12077
  ${pollInterruptedText(absolute)}
@@ -11864,10 +12087,25 @@ ${pollInterruptedText(absolute)}
11864
12087
  narrateTicks: shouldNarratePollWaitTicks({ isTTY: process.stderr.isTTY })
11865
12088
  });
11866
12089
  try {
11867
- const response = await fetchJson(`${baseUrl}/api/poll?file=${encodeURIComponent(absolute)}${timeoutQuery}`, {
11868
- retries: 3,
11869
- retryDelayMs: 500
12090
+ const request = agentReply || takeover ? {
12091
+ method: "POST",
12092
+ headers: { "content-type": "application/json" },
12093
+ body: JSON.stringify(agentReply ? { agent_reply: agentReply } : {})
12094
+ } : {};
12095
+ const response = await fetchJson(`${baseUrl}/api/poll?${query}`, {
12096
+ ...request,
12097
+ // Poll ownership is claimed before the response is available. Retrying a transport failure
12098
+ // can leave the first claim alive and make the retry reject itself as LISTENER_ACTIVE.
12099
+ retries: 0,
12100
+ onResponse: (pollResponse) => {
12101
+ if (pollResponse.headers.get(POLL_STATE_HEADER) === "listening") notifyHerdrPollReady();
12102
+ }
11870
12103
  });
12104
+ if (response.code === "LISTENER_REPLACED") {
12105
+ throw new AxiError("Lavish Editor poll listener was replaced by a takeover", "LISTENER_REPLACED", [
12106
+ `Re-run lavish-axi poll ${absolute} only if you intend to take over listening`
12107
+ ]);
12108
+ }
11871
12109
  return createPollOutput({ file: absolute, response, agent: detectInvokingAgent(process.env) });
11872
12110
  } finally {
11873
12111
  waitReporter?.stop();
@@ -12726,7 +12964,12 @@ async function serverCommand(args) {
12726
12964
  }
12727
12965
  async function visibleSessions() {
12728
12966
  const store = new SessionStore(stateFile());
12729
- return (await store.listSessions()).filter((session) => session.status !== "ended");
12967
+ const sessions = (await store.listSessions()).filter((session) => session.status !== "ended");
12968
+ const { health } = await findRunningServer(defaultPort());
12969
+ const listeners = new Map(
12970
+ Array.isArray(health?.listeners) ? health.listeners.filter((listener) => listener && typeof listener.key === "string").map((listener) => [listener.key, listener.label || "agent-listener"]) : []
12971
+ );
12972
+ return sessions.map((session) => ({ ...session, listener: listeners.get(session.key) || "none" }));
12730
12973
  }
12731
12974
  async function assertHtmlFile(file) {
12732
12975
  if (!isHtmlPath(file)) {
@@ -12949,11 +13192,15 @@ function createServerSpawnOptions(logFd = null) {
12949
13192
  env: { ...process.env, LAVISH_AXI_NO_OPEN: "1" }
12950
13193
  };
12951
13194
  }
12952
- async function fetchJson(url, { retries = 0, retryDelayMs = 250 } = {}) {
13195
+ async function fetchJson(url, { retries = 0, retryDelayMs = 250, onResponse = null, method = "GET", headers, body } = {}) {
12953
13196
  let response;
12954
13197
  for (let attempt = 0; attempt <= retries; attempt += 1) {
12955
13198
  try {
12956
- response = await fetch(url);
13199
+ response = await fetch(url, {
13200
+ method,
13201
+ ...headers ? { headers } : {},
13202
+ ...body === void 0 ? {} : { body }
13203
+ });
12957
13204
  break;
12958
13205
  } catch (error) {
12959
13206
  if (error instanceof AxiError) throw error;
@@ -12963,8 +13210,25 @@ async function fetchJson(url, { retries = 0, retryDelayMs = 250 } = {}) {
12963
13210
  }
12964
13211
  if (!response) throw serverConnectionError();
12965
13212
  if (!response.ok) {
13213
+ let payload = null;
13214
+ try {
13215
+ payload = await response.json();
13216
+ } catch {
13217
+ }
13218
+ if (payload?.code) {
13219
+ const holder = payload.code.startsWith("LISTENER_") && payload.holder;
13220
+ const holderDetail = holder && typeof holder.label === "string" && Number.isFinite(holder.age_ms) ? ` (current listener: ${holder.label}; active for ${Math.max(0, holder.age_ms)}ms)` : "";
13221
+ const error = new AxiError(
13222
+ `${payload.error || `Lavish Editor request failed: ${response.status}`}${holderDetail}`,
13223
+ payload.code,
13224
+ ["Use --takeover only when you intend to displace the current listener"]
13225
+ );
13226
+ if (holder) Object.assign(error, { holder });
13227
+ throw error;
13228
+ }
12966
13229
  throw new AxiError(`Lavish Editor request failed: ${response.status}`, "SERVER_ERROR");
12967
13230
  }
13231
+ onResponse?.(response);
12968
13232
  try {
12969
13233
  return await response.json();
12970
13234
  } catch {
@@ -13060,7 +13324,7 @@ async function readAgentReplyStream(stream) {
13060
13324
  chunks.push(chunk);
13061
13325
  }
13062
13326
  const text = Buffer.concat(chunks, bytes).toString("utf8");
13063
- if (Buffer.byteLength(JSON.stringify({ text })) > AGENT_REPLY_JSON_LIMIT_BYTES) {
13327
+ if (Buffer.byteLength(JSON.stringify({ agent_reply: text })) > AGENT_REPLY_JSON_LIMIT_BYTES) {
13064
13328
  throw agentReplyTooLargeError();
13065
13329
  }
13066
13330
  return text;
@@ -13143,7 +13407,7 @@ function createTopLevelHelp({ agent = "generic" } = {}) {
13143
13407
  Usage:
13144
13408
  lavish-axi
13145
13409
  lavish-axi <html-file> [--no-open] [--no-gate] [--reopen]
13146
- lavish-axi poll <html-file> [--agent-reply "..."] [--agent-reply-file <path>]
13410
+ lavish-axi poll <html-file> [--owner <label>] [--takeover] [--agent-reply "..."] [--agent-reply-file <path>]
13147
13411
  lavish-axi end <html-file>
13148
13412
  lavish-axi export <html-file> [--out <path>]
13149
13413
  lavish-axi share <html-file> [--private | --password <pw>] [--token <t>]
@@ -13167,9 +13431,11 @@ function createCommandHelp({ agent = "generic" } = {}) {
13167
13431
 
13168
13432
  Open 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.
13169
13433
  `,
13170
- poll: `Usage: lavish-axi poll <html-file> [--agent-reply "..."] [--agent-reply-file <path>]
13434
+ poll: `Usage: lavish-axi poll <html-file> [--owner <label>] [--takeover] [--agent-reply "..."] [--agent-reply-file <path>]
13435
+
13436
+ This command exclusively long-polls for queued user prompts. Pass --owner <label> to make the active listener visible in session listings; --takeover displaces an existing listener, which receives LISTENER_REPLACED. A second poll without --takeover fails with LISTENER_ACTIVE instead of silently returning waiting.
13171
13437
 
13172
- This 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 a concise response in Lavish Editor before waiting again. ${POLL_AGENT_REPLY_RULE} ${POLL_AGENT_REPLY_HELP_POINTER} Do not combine --agent-reply with --agent-reply-file.
13438
+ This 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. In a Herdr-managed pane, set LAVISH_AXI_HERDR_CHIME=1 to request attention when the poll has entered its waiting state; unset it or use any other value to keep the current silent behavior. Notification failures never interrupt the poll. ${pollExecutionGuidance({ agent })} Use --agent-reply after applying prior feedback to display a concise response in Lavish Editor before waiting again. ${POLL_AGENT_REPLY_RULE} ${POLL_AGENT_REPLY_HELP_POINTER} Do not combine --agent-reply with --agent-reply-file.
13173
13439
 
13174
13440
  Examples:
13175
13441
  lavish-axi poll report.html --agent-reply "Renamed the payment step."
package/dist/server.mjs CHANGED
@@ -51,9 +51,18 @@ function chunkToString(chunk, encoding) {
51
51
 
52
52
  // bin/lavish-axi-server.js
53
53
  installServerStdioTimestamps();
54
+ var fatalExitScheduled = false;
55
+ function handleFatalServerError(kind, error) {
56
+ const detail = error instanceof Error ? error.stack || error.message : String(error);
57
+ console.error(`[lavish] ${kind}: ${detail}`);
58
+ if (fatalExitScheduled) return;
59
+ fatalExitScheduled = true;
60
+ process.exitCode = 1;
61
+ setImmediate(() => process.exit(1));
62
+ }
63
+ process.on("uncaughtException", (error) => handleFatalServerError("uncaught exception", error));
54
64
  try {
55
65
  await import("./cli.mjs");
56
66
  } catch (error) {
57
- console.error(error);
58
- process.exit(1);
67
+ handleFatalServerError("server startup failed", error);
59
68
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lavish-axi",
3
- "version": "0.1.73",
3
+ "version": "0.1.75",
4
4
  "packageManager": "pnpm@11.1.1",
5
5
  "description": "HTML is the new markdown. Lavish is the new editor for your HTML artifacts.",
6
6
  "type": "module",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "lavish-axi",
4
- "version": "0.1.73",
4
+ "version": "0.1.75",
5
5
  "description": "HTML is the new markdown. Lavish is the new editor for your HTML artifacts.",
6
6
  "author": {
7
7
  "name": "Kun Chen",