lavish-axi 0.1.64 → 0.1.66
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 +2 -0
- package/dist/chrome-client.js +114 -37
- package/dist/chrome.css +7 -0
- package/dist/cli.mjs +136 -109
- package/package.json +3 -2
- package/plugin.json +1 -1
package/README.md
CHANGED
|
@@ -199,6 +199,7 @@ pnpm link
|
|
|
199
199
|
Mark only custom (non-native) clickable elements with `data-lavish-action` so Lavish does not annotate them, and use `data-lavish-question` or `queueKey` when pre-send updates for the same question should replace each other.
|
|
200
200
|
On wider screens, queued annotation preview pills and chat history share a scrollable Conversation panel above a sticky composer, so long feedback queues do not push the text box or send controls off screen.
|
|
201
201
|
The browser chrome keeps editing actions in the overflow menu (copy path, reload artifact, copy DOM snapshot, export standalone HTML, publish link, end session), while the composer exposes **Send & End** beside **Send to Agent** to submit queued prompts and user-ended attribution together.
|
|
202
|
+
Composer feedback is visibly queued before the text box clears and stays queued until acknowledged, with persistent recovery guidance if sending fails or takes longer than 10 seconds.
|
|
202
203
|
- **Reviewing on a phone** - Below 860px wide, the artifact takes the whole screen above a **Conversation** dock, and the conversation opens as a bottom sheet over it: tap the dock, swipe it up, or press the chevron to raise it; tap the dimmed artifact, swipe the sheet down, press the chevron, or press Escape to lower it. The dock reports what matters while the sheet is down - how many prompts are queued, a reply that arrived while you were reading, or whether the agent is listening - and the sheet stays open across a reload of the review page. The sheet sizes itself to the visible viewport and respects safe-area insets; if the keyboard or attachments leave little room, conversation content yields or scrolls while the send actions remain pinned above the bottom edge. In landscape the sheet covers the top bar as well. Wider screens keep the side-by-side layout.
|
|
203
204
|
- **Keyboard shortcuts** - In the chrome composer, Enter sends queued prompts and Shift+Enter inserts a newline.
|
|
204
205
|
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.
|
|
@@ -234,6 +235,7 @@ pnpm link
|
|
|
234
235
|
- **Server cleanup** - The detached server stops after the last session ends when nothing is connected, or after `LAVISH_AXI_IDLE_TIMEOUT_MS` (default 30 minutes) with no browser or poll connections.
|
|
235
236
|
Set `LAVISH_AXI_IDLE_TIMEOUT_MS=0` or `off` to disable idle self-shutdown.
|
|
236
237
|
- **Server upgrades** - One background server serves every session, so upgrading `lavish-axi` while reviews are open makes the next `lavish-axi <html-file>` 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.
|
|
238
|
+
A page still using the legacy event stream reloads once when it reconnects to the replacement server, moving itself onto the current transport without retaining an HTTP connection. If unsent annotation text is open, it shows the neutral server-restarted banner instead and waits for you to reload.
|
|
237
239
|
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.
|
|
238
240
|
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**.
|
|
239
241
|
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.
|
package/dist/chrome-client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* global
|
|
1
|
+
/* global document, location, window */
|
|
2
2
|
|
|
3
3
|
const sessionDataElement = document.getElementById("lavish-session");
|
|
4
4
|
const sessionData = JSON.parse(sessionDataElement?.textContent || "{}");
|
|
@@ -216,6 +216,8 @@ let endAfterSubmit = false;
|
|
|
216
216
|
let workingBubble = null;
|
|
217
217
|
let submitQueuedPromise = null;
|
|
218
218
|
let submitQueuedAgain = false;
|
|
219
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
220
|
+
let sendAcknowledgementTimer;
|
|
219
221
|
let lastScroll = { x: 0, y: 0 };
|
|
220
222
|
// In-iframe review context (an open annotation card's unsent text, Lavish-owned question
|
|
221
223
|
// answers). The sandbox means the chrome cannot read it back after a reload, so the SDK reports
|
|
@@ -243,6 +245,14 @@ const CHROME_RESTART_PROBE_MS = 100;
|
|
|
243
245
|
const CHROME_RESTART_SLOW_PROBE_MS = 500;
|
|
244
246
|
// A probe must always settle, so the control that is waiting on it always comes back.
|
|
245
247
|
const HEALTH_PROBE_TIMEOUT_MS = 4000;
|
|
248
|
+
// A send starts before the artifact snapshot arrives and ends only when /prompts acknowledges the
|
|
249
|
+
// batch. Bound that whole wait so a missing SDK response or a browser/network stall can never look
|
|
250
|
+
// like a dead button while the user's queue remains safely stored in this tab.
|
|
251
|
+
const SEND_ACKNOWLEDGEMENT_WARNING_MS = 10_000;
|
|
252
|
+
const SEND_STALLED_COPY =
|
|
253
|
+
"Still trying to send. Your feedback is saved in this tab. Keep this tab open while Lavish catches up, and check that the server is running.";
|
|
254
|
+
const SEND_FAILED_COPY =
|
|
255
|
+
"Could not send. Your feedback is still queued in this tab. Check that Lavish is running, then click Send to Agent to retry.";
|
|
246
256
|
const HEALTH_NO_ANSWER_TITLE = "Lavish did not answer.";
|
|
247
257
|
const HEALTH_NO_ANSWER_COPY =
|
|
248
258
|
"Lavish did not answer the check, so this page cannot tell whether it is running. Try again in a moment.";
|
|
@@ -263,6 +273,7 @@ let artifactSilenceTimer;
|
|
|
263
273
|
let copyHintTimer;
|
|
264
274
|
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
265
275
|
let sendHintTimer;
|
|
276
|
+
let sendHintPersistent = false;
|
|
266
277
|
|
|
267
278
|
function artifactFrameSrcForLoad(load) {
|
|
268
279
|
const separator = artifactSrc.includes("?") ? "&" : "?";
|
|
@@ -476,20 +487,50 @@ function pillAttachmentsHtml(prompt) {
|
|
|
476
487
|
|
|
477
488
|
const DEFAULT_SEND_HINT = "Write a message or annotate an element first.";
|
|
478
489
|
|
|
479
|
-
function showSendHint(message = DEFAULT_SEND_HINT, holdMs = 2600) {
|
|
490
|
+
function showSendHint(message = DEFAULT_SEND_HINT, holdMs = 2600, focusInput = true) {
|
|
480
491
|
sendHint.textContent = message;
|
|
481
492
|
sendHint.hidden = false;
|
|
482
493
|
clearTimeout(sendHintTimer);
|
|
494
|
+
sendHintPersistent = holdMs === null;
|
|
495
|
+
sendHint.classList.toggle("persistent", sendHintPersistent);
|
|
496
|
+
if (sendHintPersistent) {
|
|
497
|
+
sendHintTimer = undefined;
|
|
498
|
+
if (focusInput) chatInput.focus();
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
483
501
|
sendHintTimer = setTimeout(() => {
|
|
484
502
|
sendHint.hidden = true;
|
|
485
503
|
sendHint.textContent = DEFAULT_SEND_HINT;
|
|
504
|
+
sendHintPersistent = false;
|
|
486
505
|
}, holdMs);
|
|
487
|
-
chatInput.focus();
|
|
506
|
+
if (focusInput) chatInput.focus();
|
|
488
507
|
}
|
|
489
508
|
|
|
490
|
-
function hideSendHint() {
|
|
509
|
+
function hideSendHint(force = false) {
|
|
510
|
+
if (sendHintPersistent && force !== true) return;
|
|
491
511
|
clearTimeout(sendHintTimer);
|
|
492
512
|
sendHint.hidden = true;
|
|
513
|
+
sendHint.textContent = DEFAULT_SEND_HINT;
|
|
514
|
+
sendHint.classList.remove("persistent");
|
|
515
|
+
sendHintPersistent = false;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function armSendAcknowledgementWarning() {
|
|
519
|
+
if (sendAcknowledgementTimer || !queued.length) return;
|
|
520
|
+
sendAcknowledgementTimer = setTimeout(() => {
|
|
521
|
+
sendAcknowledgementTimer = undefined;
|
|
522
|
+
if (queued.length) showSendHint(SEND_STALLED_COPY, null, false);
|
|
523
|
+
}, SEND_ACKNOWLEDGEMENT_WARNING_MS);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function clearSendAcknowledgementWarning() {
|
|
527
|
+
clearTimeout(sendAcknowledgementTimer);
|
|
528
|
+
sendAcknowledgementTimer = undefined;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function showQueuedSendFailure(message = SEND_FAILED_COPY) {
|
|
532
|
+
clearSendAcknowledgementWarning();
|
|
533
|
+
if (queued.length) showSendHint(message, null, false);
|
|
493
534
|
}
|
|
494
535
|
|
|
495
536
|
function setMenuOpen(button, menu, open) {
|
|
@@ -583,15 +624,6 @@ function setHandoffSuperseded(visible) {
|
|
|
583
624
|
// the shutdown names its reason and each one gets its own line - a page told "Lavish was updated"
|
|
584
625
|
// after a deliberate stop is being told something false. An unnamed reason (SIGTERM, or any
|
|
585
626
|
// caller that names none) claims neither.
|
|
586
|
-
// Both shutdown events carry the reason the same way, so both render from one rule.
|
|
587
|
-
function shutdownEventReason(event) {
|
|
588
|
-
try {
|
|
589
|
-
return String(JSON.parse(event?.data || "{}").reason || "");
|
|
590
|
-
} catch {
|
|
591
|
-
return "";
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
|
|
595
627
|
function chromeOutdatedCopy(reason) {
|
|
596
628
|
if (reason === "upgrade") return "Lavish was updated. This page is running the previous version.";
|
|
597
629
|
if (reason === "local-build") {
|
|
@@ -909,6 +941,10 @@ function removeQueuedPrompt(index, event) {
|
|
|
909
941
|
if (event) event.stopPropagation();
|
|
910
942
|
queued.splice(index, 1);
|
|
911
943
|
persistQueuedPrompts();
|
|
944
|
+
if (!queued.length) {
|
|
945
|
+
clearSendAcknowledgementWarning();
|
|
946
|
+
hideSendHint(true);
|
|
947
|
+
}
|
|
912
948
|
render();
|
|
913
949
|
}
|
|
914
950
|
|
|
@@ -1170,17 +1206,20 @@ function sendQueued(endAfter) {
|
|
|
1170
1206
|
if (attachments.length) prompt.attachments = attachments;
|
|
1171
1207
|
queued.push(prompt);
|
|
1172
1208
|
persistQueuedPrompts();
|
|
1209
|
+
// Render the durable queue pill before clearing the editor. If anything after this point
|
|
1210
|
+
// fails, the user's words are already both stored and visibly recoverable in the tab.
|
|
1211
|
+
render();
|
|
1173
1212
|
addChat("user", text || "Image message");
|
|
1174
1213
|
chatInput.value = "";
|
|
1175
1214
|
chatAttachmentController.reset();
|
|
1176
|
-
render();
|
|
1177
1215
|
}
|
|
1178
1216
|
}
|
|
1179
1217
|
if (!queued.length) {
|
|
1180
1218
|
if (!chipsBlocked) showSendHint();
|
|
1181
1219
|
return;
|
|
1182
1220
|
}
|
|
1183
|
-
hideSendHint();
|
|
1221
|
+
hideSendHint(true);
|
|
1222
|
+
armSendAcknowledgementWarning();
|
|
1184
1223
|
|
|
1185
1224
|
if (endAfter && !chipsBlocked) endAfterSubmit = true;
|
|
1186
1225
|
requestSnapshot("submit");
|
|
@@ -1220,24 +1259,34 @@ async function submitQueuedOnce() {
|
|
|
1220
1259
|
const shouldEndSession = endAfterSubmit;
|
|
1221
1260
|
const body = { prompts: prompts.map(stripInternalPromptFields), domSnapshot: pendingSnapshot };
|
|
1222
1261
|
if (shouldEndSession) body.endSession = true;
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1262
|
+
let response;
|
|
1263
|
+
try {
|
|
1264
|
+
response = await fetch("/api/" + key + "/prompts", {
|
|
1265
|
+
method: "POST",
|
|
1266
|
+
headers: { "content-type": "application/json" },
|
|
1267
|
+
body: JSON.stringify(body),
|
|
1268
|
+
});
|
|
1269
|
+
} catch (error) {
|
|
1270
|
+
showQueuedSendFailure();
|
|
1271
|
+
throw error;
|
|
1272
|
+
}
|
|
1228
1273
|
if (!response.ok) {
|
|
1229
1274
|
if (response.status === 409) {
|
|
1230
1275
|
const data = await response.json().catch(() => null);
|
|
1231
1276
|
// The session already ended before this batch arrived - most likely this chrome missed the
|
|
1232
|
-
//
|
|
1277
|
+
// live `ended` event (a dropped connection). Go read-only now instead of leaving Send enabled
|
|
1233
1278
|
// for another attempt that will be refused the same way.
|
|
1234
1279
|
if (data?.status === "ended") {
|
|
1280
|
+
clearSendAcknowledgementWarning();
|
|
1235
1281
|
endAfterSubmit = false;
|
|
1236
1282
|
markSessionEnded();
|
|
1237
1283
|
return false;
|
|
1238
1284
|
}
|
|
1239
1285
|
if (Array.isArray(data?.warnings)) setLayoutWarnings(data.warnings);
|
|
1240
1286
|
endAfterSubmit = false;
|
|
1287
|
+
showQueuedSendFailure(
|
|
1288
|
+
"Could not send because the layout issue selection changed. Your feedback is still queued. Review the current issues, then click Send to Agent to retry.",
|
|
1289
|
+
);
|
|
1241
1290
|
return false;
|
|
1242
1291
|
}
|
|
1243
1292
|
// C4: the server persisted nothing (atomic reject) - the queue below is left
|
|
@@ -1246,8 +1295,12 @@ async function submitQueuedOnce() {
|
|
|
1246
1295
|
if (response.status === 400) {
|
|
1247
1296
|
const detail = await response.json().catch(() => ({}));
|
|
1248
1297
|
if (Array.isArray(detail.rejected) && detail.rejected.length) {
|
|
1249
|
-
|
|
1298
|
+
showQueuedSendFailure(describeAttachmentRejection(detail.rejected, detail.caps));
|
|
1299
|
+
} else {
|
|
1300
|
+
showQueuedSendFailure();
|
|
1250
1301
|
}
|
|
1302
|
+
} else {
|
|
1303
|
+
showQueuedSendFailure();
|
|
1251
1304
|
}
|
|
1252
1305
|
throw new Error("failed to submit queued prompts");
|
|
1253
1306
|
}
|
|
@@ -1257,6 +1310,9 @@ async function submitQueuedOnce() {
|
|
|
1257
1310
|
}
|
|
1258
1311
|
persistQueuedPrompts();
|
|
1259
1312
|
render();
|
|
1313
|
+
clearSendAcknowledgementWarning();
|
|
1314
|
+
hideSendHint(true);
|
|
1315
|
+
if (queued.length) armSendAcknowledgementWarning();
|
|
1260
1316
|
if (shouldEndSession) {
|
|
1261
1317
|
endAfterSubmit = false;
|
|
1262
1318
|
markSessionEnded();
|
|
@@ -3178,7 +3234,7 @@ chatComposer.addEventListener("drop", (event) => {
|
|
|
3178
3234
|
chatAttachmentController.rejectUnsupported(files);
|
|
3179
3235
|
});
|
|
3180
3236
|
// A file drop that misses the composer must not navigate the chrome away from
|
|
3181
|
-
// the session (losing chips, uploads, and the
|
|
3237
|
+
// the session (losing chips, uploads, and the live-event connection). Text drags stay
|
|
3182
3238
|
// untouched so dropping text into the textarea keeps working.
|
|
3183
3239
|
document.addEventListener("dragover", (event) => {
|
|
3184
3240
|
if (Array.from(event.dataTransfer?.types || []).includes("Files")) event.preventDefault();
|
|
@@ -3199,7 +3255,7 @@ chatInput.addEventListener("keydown", (event) => {
|
|
|
3199
3255
|
sendQueued(false);
|
|
3200
3256
|
}
|
|
3201
3257
|
});
|
|
3202
|
-
chatInput.addEventListener("input", hideSendHint);
|
|
3258
|
+
chatInput.addEventListener("input", () => hideSendHint());
|
|
3203
3259
|
copyPathButton.onclick = copyFilePath;
|
|
3204
3260
|
reloadArtifactButton.onclick = reloadArtifact;
|
|
3205
3261
|
copySnapshotButton.onclick = copyDomSnapshot;
|
|
@@ -3278,27 +3334,48 @@ frame.addEventListener("load", () => {
|
|
|
3278
3334
|
|
|
3279
3335
|
initializeLayoutGate();
|
|
3280
3336
|
|
|
3281
|
-
|
|
3282
|
-
events
|
|
3337
|
+
// WebSockets leave the browser's HTTP connection pool free for sends and artifact loads.
|
|
3338
|
+
const events = new Map();
|
|
3339
|
+
let eventReconnectDelayMs = 500;
|
|
3340
|
+
function connectLiveEvents() {
|
|
3341
|
+
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
|
3342
|
+
const socket = new WebSocket(protocol + "//" + location.host + "/events/" + encodeURIComponent(key));
|
|
3343
|
+
socket.addEventListener("open", () => {
|
|
3344
|
+
eventReconnectDelayMs = 500;
|
|
3345
|
+
refreshLayoutWarnings();
|
|
3346
|
+
});
|
|
3347
|
+
socket.addEventListener("message", (message) => {
|
|
3348
|
+
try {
|
|
3349
|
+
const { type, data } = JSON.parse(message.data);
|
|
3350
|
+
return events.get(type)?.(data || {});
|
|
3351
|
+
} catch {
|
|
3352
|
+
// Ignore malformed frames; a later event or reconnect can recover the stream.
|
|
3353
|
+
}
|
|
3354
|
+
});
|
|
3355
|
+
socket.addEventListener("close", () => {
|
|
3356
|
+
setTimeout(connectLiveEvents, eventReconnectDelayMs);
|
|
3357
|
+
eventReconnectDelayMs = Math.min(eventReconnectDelayMs * 2, 5000);
|
|
3358
|
+
});
|
|
3359
|
+
}
|
|
3360
|
+
|
|
3361
|
+
events.set("reload", () => {
|
|
3283
3362
|
resetFrame().then((reloaded) => {
|
|
3284
3363
|
if (reloaded) refreshWhiteboardSource();
|
|
3285
3364
|
});
|
|
3286
3365
|
});
|
|
3287
|
-
events.
|
|
3366
|
+
events.set("chrome-reload", (data) => reloadAfterServerRestart(String(data.reason || "")));
|
|
3288
3367
|
// The replacement server serves a different artifact's review. This page keeps working against
|
|
3289
3368
|
// it; it is only running the previous version of the chrome, which is the user's to act on.
|
|
3290
|
-
events.
|
|
3291
|
-
events.
|
|
3292
|
-
const text = JSON.parse(event.data).text;
|
|
3369
|
+
events.set("chrome-outdated", (data) => setChromeOutdated(true, String(data.reason || "")));
|
|
3370
|
+
events.set("agent-reply", ({ text }) => {
|
|
3293
3371
|
addChat("agent", text);
|
|
3294
3372
|
noteAgentReply(text);
|
|
3295
3373
|
});
|
|
3296
|
-
events.
|
|
3297
|
-
events.
|
|
3298
|
-
events.
|
|
3299
|
-
events.
|
|
3300
|
-
|
|
3301
|
-
events.addEventListener("open", () => refreshLayoutWarnings());
|
|
3374
|
+
events.set("chat-sync", (data) => syncChat(data.chat || []));
|
|
3375
|
+
events.set("agent-presence", (data) => setAgentPresence(data.state));
|
|
3376
|
+
events.set("layout-warnings", (data) => setLayoutWarnings(data.warnings || []));
|
|
3377
|
+
events.set("ended", () => markSessionEnded());
|
|
3378
|
+
connectLiveEvents();
|
|
3302
3379
|
|
|
3303
3380
|
applySheetState();
|
|
3304
3381
|
render();
|
|
@@ -3308,7 +3385,7 @@ renderWarnings();
|
|
|
3308
3385
|
initialChat.forEach((item) => addChat(item.role, item.text));
|
|
3309
3386
|
retiredDrafts.forEach((text) => renderRetiredDraft(text));
|
|
3310
3387
|
setAgentPresence("waiting");
|
|
3311
|
-
// The session already ended before this page (re)loaded, so there is no future
|
|
3388
|
+
// The session already ended before this page (re)loaded, so there is no future live `ended` event
|
|
3312
3389
|
// to wait for - start read-only instead of looking live until a Send gets silently refused.
|
|
3313
3390
|
if (sessionData.initialEnded) markSessionEnded();
|
|
3314
3391
|
|
package/dist/chrome.css
CHANGED
|
@@ -1300,6 +1300,13 @@ body.lavish {
|
|
|
1300
1300
|
font-size: 11px;
|
|
1301
1301
|
line-height: 1.35;
|
|
1302
1302
|
}
|
|
1303
|
+
.send-hint.persistent {
|
|
1304
|
+
padding: var(--space-4);
|
|
1305
|
+
border: 1px solid var(--amber-700);
|
|
1306
|
+
border-radius: var(--radius-sm);
|
|
1307
|
+
background: var(--amber-900);
|
|
1308
|
+
color: var(--accent-hover);
|
|
1309
|
+
}
|
|
1303
1310
|
.send-hint[hidden] {
|
|
1304
1311
|
display: none;
|
|
1305
1312
|
}
|
package/dist/cli.mjs
CHANGED
|
@@ -78,7 +78,8 @@ var PLAYBOOKS = [
|
|
|
78
78
|
],
|
|
79
79
|
lavish_notes: [
|
|
80
80
|
"A Lavish table should make individual rows easy annotation targets.",
|
|
81
|
-
"If a row implies a follow-up change, include an action control that queues a specific prompt."
|
|
81
|
+
"If a row implies a follow-up change, include an action control that queues a specific prompt.",
|
|
82
|
+
"When one action covers multiple rows and completeness matters, also follow the input playbook's tracked batch pattern so the reviewer submits one explicit ID set for item-by-item accounting."
|
|
82
83
|
]
|
|
83
84
|
},
|
|
84
85
|
{
|
|
@@ -201,10 +202,12 @@ var PLAYBOOKS = [
|
|
|
201
202
|
choose: [
|
|
202
203
|
"Use this when the user needs to select, tune, triage, annotate, or edit a structured choice.",
|
|
203
204
|
"Use controls for decisions the user can make faster visually than by writing a prompt.",
|
|
205
|
+
"Use an opt-in tracked batch when the agent must preserve completeness across a multi-item decision, such as findings to fixes, constraints to implementation, or recommendations to follow-up work.",
|
|
204
206
|
"Use plain annotations when the artifact only needs open-ended feedback."
|
|
205
207
|
],
|
|
206
208
|
structure: [
|
|
207
209
|
"Make each decision surface visible: what is being chosen, what the options mean, and what happens next.",
|
|
210
|
+
"For a tracked batch, give every candidate item a short, stable, visible ID and let the reviewer select or disposition items with editable native controls.",
|
|
208
211
|
"Keep reversible selection state local in the artifact until the user explicitly submits that question.",
|
|
209
212
|
"Pair each question with a Submit or Queue answer control that sends exactly one prompt for the final answer.",
|
|
210
213
|
"Show selected state separately from queued state so the user trusts what will be sent back."
|
|
@@ -216,6 +219,7 @@ var PLAYBOOKS = [
|
|
|
216
219
|
"Put data-lavish-action only on custom (non-native) elements that should act like a feedback control - typically a styled div or span you made clickable - so Lavish does not annotate it and shows a pointer cursor instead.",
|
|
217
220
|
"Use data-lavish-question on a question wrapper or pass queueKey when multiple pre-send updates should replace the prior unsent answer for the same question.",
|
|
218
221
|
"Pass options such as tag, text, selector, target, data, queueKey, or element when they help the agent understand exactly what the user chose.",
|
|
222
|
+
"For a tracked batch, queue the final selected set once in a concise, bounded data.items array; include each item's stable ID, concise label, and requested disposition, and explicitly tell the agent to account for every submitted ID before reporting completion.",
|
|
219
223
|
"Call window.lavish.sendQueuedPrompts() only when the control should immediately send committed feedback instead of waiting for the user to press Send to Agent.",
|
|
220
224
|
"Make queued prompts specific enough that the agent can act without asking a follow-up question.",
|
|
221
225
|
"Keep native browser controls accessible and readable on mobile."
|
|
@@ -229,6 +233,8 @@ var PLAYBOOKS = [
|
|
|
229
233
|
lavish_notes: [
|
|
230
234
|
"Lavish is strongest when the artifact becomes a focused review surface and not just a static page.",
|
|
231
235
|
`A native single-choice question should submit the final value: \`<form data-lavish-question="plan" onsubmit="event.preventDefault(); const choice = new FormData(event.currentTarget).get('plan'); if (choice) window.lavish.queuePrompt('Use the ' + choice + ' plan', { tag: 'choice', text: 'Plan: ' + choice, element: event.currentTarget, data: { question: 'plan', answer: choice } });"><label><input type="radio" name="plan" value="Starter"> Starter</label><label><input type="radio" name="plan" value="Pro"> Pro</label><button type="submit">Queue this answer</button></form>\`.`,
|
|
236
|
+
`A tracked batch should submit the final selected set once: <form data-lavish-question="tracked-review" onsubmit="event.preventDefault(); const selected = [...event.currentTarget.querySelectorAll('input[name=items]:checked')].map((input) => ({ id: input.value, label: input.dataset.label, disposition: input.dataset.disposition })); if (selected.length) window.lavish.queuePrompt('Act on every selected item and return an item-by-item receipt. Account for every submitted ID before reporting completion.', { tag: 'tracked-batch', text: 'Apply ' + selected.length + ' selected review items', element: event.currentTarget, data: { items: selected } });"><label><input type="checkbox" name="items" value="R-03" data-label="Preserve rollback behavior" data-disposition="must-address"> R-03 \u2014 Preserve rollback behavior</label><label><input type="checkbox" name="items" value="R-08" data-label="Reuse the existing error surface" data-disposition="must-address"> R-08 \u2014 Reuse the existing error surface</label><button type="submit">Queue selected items</button></form>`,
|
|
237
|
+
"The agent's tracked-batch completion receipt must give every submitted ID exactly one outcome: addressed with concrete evidence, deferred with a reason, or rejected with a reason. Evidence may cover several IDs. Before declaring completion, compare the submitted ID set with the receipt ID set and surface every missing ID.",
|
|
232
238
|
"A custom choice UI should make option buttons update local state, then use a separate Queue answer button with data-lavish-action to queue the final selected value.",
|
|
233
239
|
"Use window.lavish.queuePrompt for user intent, not internal analytics or UI-only state changes.",
|
|
234
240
|
"End input paths with an obvious way for the user to send feedback back to the agent."
|
|
@@ -8806,6 +8812,7 @@ var DEFAULT_IDLE_TIMEOUT_MS = 30 * 6e4;
|
|
|
8806
8812
|
var WHITEBOARD_CHANNEL_TOKEN_TTL_MS = 5 * 6e4;
|
|
8807
8813
|
var NETWORK_RECONCILE_CACHE_MS = 1e3;
|
|
8808
8814
|
var TAILSCALE_BIND_RETRY_DELAYS_MS = [100, 250, 500];
|
|
8815
|
+
var WEBSOCKET_CLOSE_GRACE_MS = 250;
|
|
8809
8816
|
var ARTIFACT_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads";
|
|
8810
8817
|
var ATTACHMENT_SWEEP_INTERVAL_MS = 60 * 6e4;
|
|
8811
8818
|
var SHUTDOWN_REASONS = /* @__PURE__ */ new Set(["upgrade", "local-build", "stop"]);
|
|
@@ -8912,6 +8919,7 @@ async function serve({
|
|
|
8912
8919
|
lookupHost,
|
|
8913
8920
|
whiteboardAssetsDir = defaultWhiteboardAssetsDir()
|
|
8914
8921
|
} = {}) {
|
|
8922
|
+
const { WebSocket, WebSocketServer } = await import("ws");
|
|
8915
8923
|
const extraHosts = allowedHosts ?? extraAllowedHosts(env);
|
|
8916
8924
|
const envHost = env.LAVISH_AXI_HOST?.trim();
|
|
8917
8925
|
const autoTailscale = !envHost;
|
|
@@ -8933,7 +8941,7 @@ async function serve({
|
|
|
8933
8941
|
const watchers = /* @__PURE__ */ new Map();
|
|
8934
8942
|
const activePolls = /* @__PURE__ */ new Map();
|
|
8935
8943
|
const deliveredFeedback = /* @__PURE__ */ new Set();
|
|
8936
|
-
const
|
|
8944
|
+
const liveEventClients = /* @__PURE__ */ new Map();
|
|
8937
8945
|
const whiteboardChannelSecret = crypto6.randomBytes(32);
|
|
8938
8946
|
const outstandingRepairBatches = /* @__PURE__ */ new Set();
|
|
8939
8947
|
const diagnosticViewportClasses = resolveDiagnosticViewportClasses();
|
|
@@ -8947,6 +8955,39 @@ async function serve({
|
|
|
8947
8955
|
let networkReconcileCheckedAt = 0;
|
|
8948
8956
|
let cachedNetworkStale = false;
|
|
8949
8957
|
let networkReconcilePromise = null;
|
|
8958
|
+
function broadcastLiveEvent(type, key, data = {}) {
|
|
8959
|
+
for (const [client, clientKey] of liveEventClients) {
|
|
8960
|
+
if (clientKey === key) client.sendEvent(type, data);
|
|
8961
|
+
}
|
|
8962
|
+
}
|
|
8963
|
+
events.on("reload", (key) => broadcastLiveEvent("reload", key));
|
|
8964
|
+
events.on("agent-reply", (key, text) => broadcastLiveEvent("agent-reply", key, { text }));
|
|
8965
|
+
events.on("agent-presence", (key, state) => broadcastLiveEvent("agent-presence", key, { state }));
|
|
8966
|
+
events.on("layout-warnings", (key, warnings) => broadcastLiveEvent("layout-warnings", key, { warnings }));
|
|
8967
|
+
events.on("ended", (key, endedBy) => broadcastLiveEvent("ended", key, { ended_by: endedBy || null }));
|
|
8968
|
+
function attachLiveEventClient(client, key, onClose) {
|
|
8969
|
+
liveEventClients.set(client, key);
|
|
8970
|
+
refreshIdleTimer();
|
|
8971
|
+
let cleanedUp = false;
|
|
8972
|
+
const cleanup = () => {
|
|
8973
|
+
if (cleanedUp) return;
|
|
8974
|
+
cleanedUp = true;
|
|
8975
|
+
liveEventClients.delete(client);
|
|
8976
|
+
refreshIdleTimer();
|
|
8977
|
+
};
|
|
8978
|
+
onClose(cleanup);
|
|
8979
|
+
return cleanup;
|
|
8980
|
+
}
|
|
8981
|
+
async function sendInitialLiveEventState(client, key, cleanup) {
|
|
8982
|
+
const session = await store.findByKey(key);
|
|
8983
|
+
if (client.isClosed()) {
|
|
8984
|
+
cleanup();
|
|
8985
|
+
return;
|
|
8986
|
+
}
|
|
8987
|
+
client.sendEvent("chat-sync", { chat: session?.chat || [] });
|
|
8988
|
+
client.sendEvent("agent-presence", { state: computePresence(key, activePolls, deliveredFeedback) });
|
|
8989
|
+
if (session?.status === "ended") client.sendEvent("ended", { ended_by: session.ended_by || null });
|
|
8990
|
+
}
|
|
8950
8991
|
async function reconcileTailscaleNetwork() {
|
|
8951
8992
|
if (Date.now() - networkReconcileCheckedAt < NETWORK_RECONCILE_CACHE_MS) return cachedNetworkStale;
|
|
8952
8993
|
if (networkReconcilePromise) return networkReconcilePromise;
|
|
@@ -9637,93 +9678,16 @@ async function serve({
|
|
|
9637
9678
|
next(error);
|
|
9638
9679
|
}
|
|
9639
9680
|
});
|
|
9640
|
-
app.get("/events/:key",
|
|
9641
|
-
|
|
9642
|
-
|
|
9643
|
-
|
|
9644
|
-
|
|
9645
|
-
|
|
9646
|
-
|
|
9647
|
-
|
|
9648
|
-
});
|
|
9649
|
-
sseClients.set(res, String(req.params.key || ""));
|
|
9650
|
-
refreshIdleTimer();
|
|
9651
|
-
const sendReload = (key) => {
|
|
9652
|
-
if (key === req.params.key) {
|
|
9653
|
-
res.write("event: reload\ndata: {}\n\n");
|
|
9654
|
-
}
|
|
9655
|
-
};
|
|
9656
|
-
const sendAgentReply = (key, text) => {
|
|
9657
|
-
if (key === req.params.key) {
|
|
9658
|
-
res.write(`event: agent-reply
|
|
9659
|
-
data: ${JSON.stringify({ text })}
|
|
9660
|
-
|
|
9661
|
-
`);
|
|
9662
|
-
}
|
|
9663
|
-
};
|
|
9664
|
-
const sendPresence = (key, state) => {
|
|
9665
|
-
if (key === req.params.key) {
|
|
9666
|
-
res.write(`event: agent-presence
|
|
9667
|
-
data: ${JSON.stringify({ state })}
|
|
9668
|
-
|
|
9669
|
-
`);
|
|
9670
|
-
}
|
|
9671
|
-
};
|
|
9672
|
-
const sendLayoutWarnings = (key, warnings) => {
|
|
9673
|
-
if (key === req.params.key) {
|
|
9674
|
-
res.write(`event: layout-warnings
|
|
9675
|
-
data: ${JSON.stringify({ warnings })}
|
|
9676
|
-
|
|
9677
|
-
`);
|
|
9678
|
-
}
|
|
9679
|
-
};
|
|
9680
|
-
const sendEnded = (key, endedBy) => {
|
|
9681
|
-
if (key === req.params.key) {
|
|
9682
|
-
res.write(`event: ended
|
|
9683
|
-
data: ${JSON.stringify({ ended_by: endedBy || null })}
|
|
9684
|
-
|
|
9685
|
-
`);
|
|
9686
|
-
}
|
|
9687
|
-
};
|
|
9688
|
-
events.on("reload", sendReload);
|
|
9689
|
-
events.on("agent-reply", sendAgentReply);
|
|
9690
|
-
events.on("agent-presence", sendPresence);
|
|
9691
|
-
events.on("layout-warnings", sendLayoutWarnings);
|
|
9692
|
-
events.on("ended", sendEnded);
|
|
9693
|
-
let cleanedUp = false;
|
|
9694
|
-
cleanup = () => {
|
|
9695
|
-
if (cleanedUp) return;
|
|
9696
|
-
cleanedUp = true;
|
|
9697
|
-
req.off("close", cleanup);
|
|
9698
|
-
sseClients.delete(res);
|
|
9699
|
-
events.off("reload", sendReload);
|
|
9700
|
-
events.off("agent-reply", sendAgentReply);
|
|
9701
|
-
events.off("agent-presence", sendPresence);
|
|
9702
|
-
events.off("layout-warnings", sendLayoutWarnings);
|
|
9703
|
-
events.off("ended", sendEnded);
|
|
9704
|
-
refreshIdleTimer();
|
|
9705
|
-
};
|
|
9706
|
-
req.once("close", cleanup);
|
|
9707
|
-
const session = await store.findByKey(req.params.key);
|
|
9708
|
-
if (req.destroyed || res.writableEnded) {
|
|
9709
|
-
cleanup();
|
|
9710
|
-
return;
|
|
9711
|
-
}
|
|
9712
|
-
res.write(`event: chat-sync
|
|
9713
|
-
data: ${JSON.stringify({ chat: session?.chat || [] })}
|
|
9681
|
+
app.get("/events/:key", (_req, res) => {
|
|
9682
|
+
res.writeHead(200, {
|
|
9683
|
+
"content-type": "text/event-stream",
|
|
9684
|
+
"cache-control": "no-cache",
|
|
9685
|
+
connection: "close"
|
|
9686
|
+
});
|
|
9687
|
+
res.end(`event: chrome-reload
|
|
9688
|
+
data: ${JSON.stringify({ reason: "server-restarted" })}
|
|
9714
9689
|
|
|
9715
9690
|
`);
|
|
9716
|
-
res.write(
|
|
9717
|
-
`event: agent-presence
|
|
9718
|
-
data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, deliveredFeedback) })}
|
|
9719
|
-
|
|
9720
|
-
`
|
|
9721
|
-
);
|
|
9722
|
-
if (session?.status === "ended") sendEnded(req.params.key, session.ended_by);
|
|
9723
|
-
} catch (error) {
|
|
9724
|
-
cleanup();
|
|
9725
|
-
next(error);
|
|
9726
|
-
}
|
|
9727
9691
|
});
|
|
9728
9692
|
app.get("/chrome-client.js", async (req, res, next) => {
|
|
9729
9693
|
try {
|
|
@@ -9965,6 +9929,74 @@ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, del
|
|
|
9965
9929
|
const status = Number(error?.statusCode || error?.status) || 500;
|
|
9966
9930
|
res.status(status).json({ error: error instanceof Error ? error.message : String(error) });
|
|
9967
9931
|
});
|
|
9932
|
+
const eventWebSocketServer = new WebSocketServer({ noServer: true, maxPayload: 1024 });
|
|
9933
|
+
function rejectEventUpgrade(socket, status, message) {
|
|
9934
|
+
const body = `${message}
|
|
9935
|
+
`;
|
|
9936
|
+
socket.end(
|
|
9937
|
+
`HTTP/1.1 ${status} ${message}\r
|
|
9938
|
+
Connection: close\r
|
|
9939
|
+
Content-Type: text/plain; charset=utf-8\r
|
|
9940
|
+
Content-Length: ${Buffer.byteLength(body)}\r
|
|
9941
|
+
\r
|
|
9942
|
+
${body}`
|
|
9943
|
+
);
|
|
9944
|
+
}
|
|
9945
|
+
function handleEventUpgrade(req, socket, head) {
|
|
9946
|
+
let pathname;
|
|
9947
|
+
try {
|
|
9948
|
+
pathname = new URL(String(req.url || ""), "http://lavish.local").pathname;
|
|
9949
|
+
} catch {
|
|
9950
|
+
rejectEventUpgrade(socket, 400, "Bad Request");
|
|
9951
|
+
return;
|
|
9952
|
+
}
|
|
9953
|
+
const match = pathname.match(/^\/events\/([^/]+)$/);
|
|
9954
|
+
if (!match) {
|
|
9955
|
+
rejectEventUpgrade(socket, 404, "Not Found");
|
|
9956
|
+
return;
|
|
9957
|
+
}
|
|
9958
|
+
const hostAllowed = allowAnyHostname ? parseHostAuthority(req.headers.host) !== null : isAllowedRequestHost(
|
|
9959
|
+
{ host: req.headers.host, forwardedHost: req.headers["x-forwarded-host"] },
|
|
9960
|
+
allowedHostnames
|
|
9961
|
+
);
|
|
9962
|
+
if (!hostAllowed || !req.headers.origin || !isSameOriginRequest(req, allowedHostnames, allowAnyHostname)) {
|
|
9963
|
+
rejectEventUpgrade(socket, 403, "Forbidden");
|
|
9964
|
+
return;
|
|
9965
|
+
}
|
|
9966
|
+
let key;
|
|
9967
|
+
try {
|
|
9968
|
+
key = decodeURIComponent(match[1]);
|
|
9969
|
+
} catch {
|
|
9970
|
+
rejectEventUpgrade(socket, 400, "Bad Request");
|
|
9971
|
+
return;
|
|
9972
|
+
}
|
|
9973
|
+
eventWebSocketServer.handleUpgrade(req, socket, head, (webSocket) => {
|
|
9974
|
+
const client = {
|
|
9975
|
+
sendEvent(type, data) {
|
|
9976
|
+
if (webSocket.readyState === WebSocket.OPEN) webSocket.send(JSON.stringify({ type, data }));
|
|
9977
|
+
},
|
|
9978
|
+
close(code = 1001, reason = "Lavish server shutdown") {
|
|
9979
|
+
webSocket.close(code, reason);
|
|
9980
|
+
const terminateTimer = setTimeout(() => {
|
|
9981
|
+
if (webSocket.readyState !== WebSocket.CLOSED) webSocket.terminate();
|
|
9982
|
+
}, WEBSOCKET_CLOSE_GRACE_MS);
|
|
9983
|
+
terminateTimer.unref?.();
|
|
9984
|
+
webSocket.once("close", () => clearTimeout(terminateTimer));
|
|
9985
|
+
},
|
|
9986
|
+
isClosed() {
|
|
9987
|
+
return webSocket.readyState === WebSocket.CLOSING || webSocket.readyState === WebSocket.CLOSED;
|
|
9988
|
+
}
|
|
9989
|
+
};
|
|
9990
|
+
webSocket.on("error", () => {
|
|
9991
|
+
});
|
|
9992
|
+
const cleanup = attachLiveEventClient(client, key, (remove) => webSocket.once("close", remove));
|
|
9993
|
+
sendInitialLiveEventState(client, key, cleanup).catch((error) => {
|
|
9994
|
+
client.close(1011, "Failed to initialize live events");
|
|
9995
|
+
cleanup();
|
|
9996
|
+
logEvent?.(`event WebSocket initialization failed session=${key}: ${error?.message || error}`);
|
|
9997
|
+
});
|
|
9998
|
+
});
|
|
9999
|
+
}
|
|
9968
10000
|
const httpServers = [];
|
|
9969
10001
|
const boundHosts = [];
|
|
9970
10002
|
let boundPort = port;
|
|
@@ -9974,6 +10006,7 @@ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, del
|
|
|
9974
10006
|
while (true) {
|
|
9975
10007
|
try {
|
|
9976
10008
|
const httpServer = await listenHttp(app, boundPort, listenHost);
|
|
10009
|
+
httpServer.on("upgrade", handleEventUpgrade);
|
|
9977
10010
|
if (boundPort === 0) boundPort = httpServer.address().port;
|
|
9978
10011
|
httpServers.push(httpServer);
|
|
9979
10012
|
boundHosts.push(listenHost);
|
|
@@ -10024,25 +10057,19 @@ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, del
|
|
|
10024
10057
|
clearInterval(attachmentSweepTimer);
|
|
10025
10058
|
attachmentSweepTimer = null;
|
|
10026
10059
|
}
|
|
10027
|
-
const shutdownData =
|
|
10028
|
-
for (const [
|
|
10060
|
+
const shutdownData = { reason };
|
|
10061
|
+
for (const [client, clientKey] of liveEventClients) {
|
|
10029
10062
|
try {
|
|
10030
10063
|
if (reloadKey && clientKey === reloadKey) {
|
|
10031
|
-
|
|
10032
|
-
data: ${shutdownData}
|
|
10033
|
-
|
|
10034
|
-
`);
|
|
10064
|
+
client.sendEvent("chrome-reload", shutdownData);
|
|
10035
10065
|
} else {
|
|
10036
|
-
|
|
10037
|
-
data: ${shutdownData}
|
|
10038
|
-
|
|
10039
|
-
`);
|
|
10066
|
+
client.sendEvent("chrome-outdated", shutdownData);
|
|
10040
10067
|
}
|
|
10041
|
-
|
|
10068
|
+
client.close();
|
|
10042
10069
|
} catch {
|
|
10043
10070
|
}
|
|
10044
10071
|
}
|
|
10045
|
-
|
|
10072
|
+
liveEventClients.clear();
|
|
10046
10073
|
for (const w of watchers.values()) {
|
|
10047
10074
|
w.close().catch(() => {
|
|
10048
10075
|
});
|
|
@@ -10067,10 +10094,10 @@ data: ${shutdownData}
|
|
|
10067
10094
|
idleTimer = null;
|
|
10068
10095
|
}
|
|
10069
10096
|
if (shuttingDown || idleTimeoutMs == null) return;
|
|
10070
|
-
if (
|
|
10097
|
+
if (liveEventClients.size > 0 || activePolls.size > 0) return;
|
|
10071
10098
|
idleTimer = setTimeout(() => {
|
|
10072
10099
|
idleTimer = null;
|
|
10073
|
-
if (!shuttingDown &&
|
|
10100
|
+
if (!shuttingDown && liveEventClients.size === 0 && activePolls.size === 0) {
|
|
10074
10101
|
logEvent?.(`idle for ${idleTimeoutMs}ms with no connections, shutting down`);
|
|
10075
10102
|
shutdown();
|
|
10076
10103
|
}
|
|
@@ -10078,7 +10105,7 @@ data: ${shutdownData}
|
|
|
10078
10105
|
idleTimer.unref?.();
|
|
10079
10106
|
}
|
|
10080
10107
|
async function shutdownIfNoLiveSessions() {
|
|
10081
|
-
if (
|
|
10108
|
+
if (liveEventClients.size > 0 || activePolls.size > 0) return;
|
|
10082
10109
|
try {
|
|
10083
10110
|
const sessions = await store.listSessions();
|
|
10084
10111
|
if (sessions.every((session) => session.status === "ended")) {
|
|
@@ -10273,24 +10300,24 @@ function hasPresentOriginOrReferer(req) {
|
|
|
10273
10300
|
function isSameOriginRequest(req, allowedHostnames, allowAnyHostname = false) {
|
|
10274
10301
|
const host = parseHostAuthority(req.headers.host);
|
|
10275
10302
|
if (!host) return false;
|
|
10276
|
-
let protocol = req.protocol;
|
|
10303
|
+
let protocol = req.protocol || "http";
|
|
10277
10304
|
let authority = host;
|
|
10278
|
-
const forwardedHost = String(req.
|
|
10305
|
+
const forwardedHost = String(req.headers["x-forwarded-host"] || "").split(",").pop().trim();
|
|
10279
10306
|
if (forwardedHost) {
|
|
10280
10307
|
const forwardedAuthority = parseHostAuthority(forwardedHost);
|
|
10281
10308
|
if (!forwardedAuthority || !allowAnyHostname && (!allowedHostnames.has(host.hostname) || !allowedHostnames.has(forwardedAuthority.hostname)))
|
|
10282
10309
|
return false;
|
|
10283
|
-
protocol = String(req.
|
|
10310
|
+
protocol = String(req.headers["x-forwarded-proto"] || protocol).split(",").pop().trim().toLowerCase();
|
|
10284
10311
|
if (protocol !== "http" && protocol !== "https") return false;
|
|
10285
10312
|
authority = forwardedAuthority;
|
|
10286
10313
|
}
|
|
10287
10314
|
const expectedOrigin = normalizeOrigin(`${protocol}://${authority.authority}`);
|
|
10288
10315
|
if (!expectedOrigin) return false;
|
|
10289
|
-
const origin = req.
|
|
10316
|
+
const origin = req.headers.origin;
|
|
10290
10317
|
if (origin) {
|
|
10291
10318
|
return normalizeOrigin(origin) === expectedOrigin;
|
|
10292
10319
|
}
|
|
10293
|
-
const referer = req.
|
|
10320
|
+
const referer = req.headers.referer;
|
|
10294
10321
|
return Boolean(referer) && normalizeOrigin(referer) === expectedOrigin;
|
|
10295
10322
|
}
|
|
10296
10323
|
function normalizeOrigin(value) {
|
|
@@ -10584,7 +10611,7 @@ function createChromeHtml(session, {
|
|
|
10584
10611
|
const sessionJson = jsonScript({
|
|
10585
10612
|
key: session.key,
|
|
10586
10613
|
file: session.file,
|
|
10587
|
-
// A page loaded (or reloaded) after the session already ended has no future
|
|
10614
|
+
// A page loaded (or reloaded) after the session already ended has no future live `ended`
|
|
10588
10615
|
// event to wait for - it must start read-only instead of looking live until the user tries
|
|
10589
10616
|
// to send and gets refused (#171).
|
|
10590
10617
|
initialEnded: session.status === "ended",
|
|
@@ -10889,7 +10916,7 @@ var POLL_WAKE_PATH_RULES = Object.freeze([
|
|
|
10889
10916
|
]);
|
|
10890
10917
|
var 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.";
|
|
10891
10918
|
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.";
|
|
10892
|
-
var VERSION = "0.1.
|
|
10919
|
+
var VERSION = "0.1.66";
|
|
10893
10920
|
function detectInvokingAgent(env = process.env) {
|
|
10894
10921
|
return ["CODEX_SANDBOX", "CODEX_THREAD_ID"].some((key) => Object.hasOwn(env, key)) ? "codex" : "generic";
|
|
10895
10922
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lavish-axi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.66",
|
|
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",
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"daisyui": "^5.5.19",
|
|
39
39
|
"express": "^5.2.1",
|
|
40
40
|
"open": "^10.2.0",
|
|
41
|
-
"parse5": "^8.0.1"
|
|
41
|
+
"parse5": "^8.0.1",
|
|
42
|
+
"ws": "^8.21.3"
|
|
42
43
|
},
|
|
43
44
|
"devDependencies": {
|
|
44
45
|
"@eslint/js": "^10.0.1",
|
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.
|
|
4
|
+
"version": "0.1.66",
|
|
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",
|