lavish-axi 0.1.82 → 0.1.84
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 +3 -1
- package/dist/chrome-client.js +67 -1
- package/dist/chrome.css +32 -0
- package/dist/cli.mjs +271 -84
- package/package.json +1 -1
- package/plugin.json +1 -1
package/README.md
CHANGED
|
@@ -211,7 +211,9 @@ pnpm link
|
|
|
211
211
|
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.
|
|
212
212
|
While editing a queued note, Enter saves it, Shift+Enter inserts a newline, and Escape discards the edit.
|
|
213
213
|
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.
|
|
214
|
+
In annotate mode a plain click on a link annotates it; Ctrl+click (Cmd+click on macOS) follows the link instead, opening it in a new tab as the browser normally would, without leaving annotate mode.
|
|
214
215
|
- **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 (see the CLI Reference for reply and poll commands). A supervisor-owned process listener is shown as an external listener rather than an idle captain turn.
|
|
216
|
+
- **Delivery receipts** - Every note you have sent carries three emoji under it that light up in turn: 👀 **Seen** when a `lavish-axi poll` takes the note, 🛠️ **Working** when the artifact file changes after that, and ✅ **Done** when the agent replies. A step that has not happened yet is the same emoji greyed out. All three come from what the server observes; the agent does nothing extra. Until 👀 lights, the row says why: no agent is listening, or the agent is busy and the note is delivered on its next poll. A note answered without touching the artifact goes Seen to Done with Working still grey. A reply marks every note the agent has seen as Done, not notes still waiting for a poll. Each emoji names its step and time on hover and to screen readers. Notes sent before receipts existed show no receipt row.
|
|
215
217
|
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.
|
|
216
218
|
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.
|
|
217
219
|
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.
|
|
@@ -296,7 +298,7 @@ For flows, architecture, state, or sequence diagrams, open the diagram playbook
|
|
|
296
298
|
| `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. |
|
|
297
299
|
| `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`. |
|
|
298
300
|
| `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`. |
|
|
299
|
-
| `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.
|
|
301
|
+
| `lavish-axi poll` | `--agent-reply "..."` | Show a concise agent reply in the existing browser chat, conclude delivered work (every note the agent has seen is marked Done), and return presence to waiting before polling again. Keep replies concise by default. |
|
|
300
302
|
| `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`. |
|
|
301
303
|
| `lavish-axi poll` | `--timeout-ms <ms>` | Test/debug escape hatch only; agents should normally omit it and leave the long poll running. |
|
|
302
304
|
| `lavish-axi reply` | `--agent-reply "..."` | Concise reply text. Required unless `--agent-reply-file` is set. The command exits 0 only after the server accepts it. Empty text is refused. Prefer this over `poll --agent-reply` when you are not about to long-poll. |
|
package/dist/chrome-client.js
CHANGED
|
@@ -912,10 +912,75 @@ function chatBubbleHtml(entry) {
|
|
|
912
912
|
"<small>You</small>" +
|
|
913
913
|
anchorHtml(entry.anchor) +
|
|
914
914
|
userBubbleTextHtml(entry, entry.text) +
|
|
915
|
-
bubbleAttachmentsHtml(entry)
|
|
915
|
+
bubbleAttachmentsHtml(entry) +
|
|
916
|
+
receiptHtml(entry)
|
|
916
917
|
);
|
|
917
918
|
}
|
|
918
919
|
|
|
920
|
+
// Delivery receipt (docs/delivery-acks.md): the per-note view of what the server observed. Seen,
|
|
921
|
+
// Working, and Done are stamps on the entry, so the row is derived from it on every render and
|
|
922
|
+
// never tracked here. Before Seen, the row says why, from the presence the live stream reports.
|
|
923
|
+
// The three steps are the emoji of the Telegram ack protocol the receipt mirrors (seen, working,
|
|
924
|
+
// done); a step that has not happened is the same glyph dimmed, and every step carries its label
|
|
925
|
+
// and time as `title` and `aria-label`, so hover and screen readers both say the word.
|
|
926
|
+
const RECEIPT_EMOJI = { Seen: "\u{1F440}", Working: "\u{1F6E0}️", Done: "✅" };
|
|
927
|
+
|
|
928
|
+
function receiptTime(at) {
|
|
929
|
+
const date = new Date(String(at));
|
|
930
|
+
return Number.isNaN(date.getTime())
|
|
931
|
+
? String(at)
|
|
932
|
+
: date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function receiptStepHtml(label, at) {
|
|
936
|
+
const ticked = Boolean(at);
|
|
937
|
+
const text = ticked ? label + " " + receiptTime(at) : label + ": not yet";
|
|
938
|
+
return (
|
|
939
|
+
'<span class="receipt-step' +
|
|
940
|
+
(ticked ? " is-done" : "") +
|
|
941
|
+
'" role="img" aria-label="' +
|
|
942
|
+
escapeHtml(text) +
|
|
943
|
+
'" title="' +
|
|
944
|
+
escapeHtml(text) +
|
|
945
|
+
'">' +
|
|
946
|
+
RECEIPT_EMOJI[label] +
|
|
947
|
+
"</span>"
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Every branch must be true where it is shown: `waiting` is no poll at all, `listening` is a poll
|
|
952
|
+
// that will take the note within milliseconds, and `working` or an external listener is an agent
|
|
953
|
+
// whose next poll will take it.
|
|
954
|
+
function receiptNoteText() {
|
|
955
|
+
if (agentPresence === "waiting") return "No agent is listening";
|
|
956
|
+
if (agentPresence === "listening") return "Delivering";
|
|
957
|
+
return "Agent is busy; delivered on its next poll";
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// A transcript entry from before receipts existed (`receipt: "none"`, marked by the server once at
|
|
961
|
+
// load) gets no row: nothing was ever observed about it, and an empty row would read as "never seen".
|
|
962
|
+
function hasReceipt(entry) {
|
|
963
|
+
return Boolean(entry) && entry.receipt !== "none";
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function receiptHtml(entry) {
|
|
967
|
+
if (!hasReceipt(entry)) return "";
|
|
968
|
+
return (
|
|
969
|
+
'<div class="receipt">' +
|
|
970
|
+
receiptStepHtml("Seen", entry.delivered_at) +
|
|
971
|
+
receiptStepHtml("Working", entry.working_at) +
|
|
972
|
+
receiptStepHtml("Done", entry.done_at) +
|
|
973
|
+
(entry.delivered_at ? "" : '<span class="receipt-note">' + escapeHtml(receiptNoteText()) + "</span>") +
|
|
974
|
+
"</div>"
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// Only the pre-Seen line names the live presence, so a presence change rewrites that line alone.
|
|
979
|
+
// Rebuilding the bubble would also rebuild its thumbnails and undo an "Image expired" placeholder.
|
|
980
|
+
function refreshUnseenReceipts() {
|
|
981
|
+
for (const note of chatLog.querySelectorAll(".receipt-note")) note.textContent = receiptNoteText();
|
|
982
|
+
}
|
|
983
|
+
|
|
919
984
|
function addChat(entry, shouldScroll = true) {
|
|
920
985
|
if (!entry || typeof entry !== "object") return;
|
|
921
986
|
const role = entry.role === "agent" ? "agent" : "user";
|
|
@@ -1067,6 +1132,7 @@ function setAgentPresence(state) {
|
|
|
1067
1132
|
agentPresence = state === "listening" || state === "external" || state === "working" ? state : "waiting";
|
|
1068
1133
|
updateSendState();
|
|
1069
1134
|
renderSheetSummary();
|
|
1135
|
+
refreshUnseenReceipts();
|
|
1070
1136
|
if (presenceBanner) presenceBanner.hidden = ended || agentPresence !== "waiting";
|
|
1071
1137
|
|
|
1072
1138
|
// A supervisor-owned process-only listener is busy on the agent's behalf. It must not make the
|
package/dist/chrome.css
CHANGED
|
@@ -1301,6 +1301,38 @@ body.lavish {
|
|
|
1301
1301
|
border-radius: 3px;
|
|
1302
1302
|
padding: 0 3px;
|
|
1303
1303
|
}
|
|
1304
|
+
/* Delivery receipt: the per-note view of agent presence (docs/delivery-acks.md). Three emoji
|
|
1305
|
+
(seen, working, done) that light up as the server observes each step; a step that has not
|
|
1306
|
+
happened is the same glyph greyed and dimmed, so the row always shows what is still to come. */
|
|
1307
|
+
.receipt {
|
|
1308
|
+
display: flex;
|
|
1309
|
+
flex-wrap: wrap;
|
|
1310
|
+
align-items: center;
|
|
1311
|
+
justify-content: flex-end;
|
|
1312
|
+
gap: 4px 8px;
|
|
1313
|
+
margin-top: 6px;
|
|
1314
|
+
font-size: 13px;
|
|
1315
|
+
line-height: 1.3;
|
|
1316
|
+
color: var(--fg-faint);
|
|
1317
|
+
}
|
|
1318
|
+
.receipt-step {
|
|
1319
|
+
display: inline-block;
|
|
1320
|
+
filter: grayscale(1);
|
|
1321
|
+
opacity: 0.3;
|
|
1322
|
+
transition:
|
|
1323
|
+
opacity 0.3s ease,
|
|
1324
|
+
filter 0.3s ease;
|
|
1325
|
+
}
|
|
1326
|
+
.receipt-step.is-done {
|
|
1327
|
+
filter: none;
|
|
1328
|
+
opacity: 1;
|
|
1329
|
+
}
|
|
1330
|
+
.receipt-note {
|
|
1331
|
+
font-size: 11px;
|
|
1332
|
+
flex-basis: 100%;
|
|
1333
|
+
text-align: right;
|
|
1334
|
+
font-style: italic;
|
|
1335
|
+
}
|
|
1304
1336
|
.bubble-attachments {
|
|
1305
1337
|
display: flex;
|
|
1306
1338
|
flex-wrap: wrap;
|
package/dist/cli.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { access, readFile as readFile6, writeFile as writeFile4 } from "node:fs/
|
|
|
21
21
|
import { get as httpGet2 } from "node:http";
|
|
22
22
|
import { isIP as isIP4 } from "node:net";
|
|
23
23
|
import os4 from "node:os";
|
|
24
|
-
import
|
|
24
|
+
import path9 from "node:path";
|
|
25
25
|
import { fileURLToPath as fileURLToPath4 } from "node:url";
|
|
26
26
|
import { AxiError, installSessionStartHooks, RESERVED_COMMANDS, runAxiCli } from "axi-sdk-js";
|
|
27
27
|
|
|
@@ -4427,7 +4427,7 @@ function htmlAppRequestError(message, details = {}) {
|
|
|
4427
4427
|
if (status !== void 0) Object.defineProperty(error, "status", { value: status, enumerable: true });
|
|
4428
4428
|
return error;
|
|
4429
4429
|
}
|
|
4430
|
-
async function requestHtmlApp({ method, path:
|
|
4430
|
+
async function requestHtmlApp({ method, path: path10, body, bearer, options, env, action = "publish" }) {
|
|
4431
4431
|
const apiUrl = (options.apiUrl ? String(options.apiUrl).replace(/\/+$/, "") : "") || htmlAppApiUrl(env);
|
|
4432
4432
|
const fetchImpl = options.fetch || fetch;
|
|
4433
4433
|
const headers = { "content-type": "application/json", "user-agent": "lavish-axi" };
|
|
@@ -4437,7 +4437,7 @@ async function requestHtmlApp({ method, path: path9, body, bearer, options, env,
|
|
|
4437
4437
|
let response;
|
|
4438
4438
|
let text;
|
|
4439
4439
|
try {
|
|
4440
|
-
response = await fetchImpl(`${apiUrl}${
|
|
4440
|
+
response = await fetchImpl(`${apiUrl}${path10}`, {
|
|
4441
4441
|
method,
|
|
4442
4442
|
headers,
|
|
4443
4443
|
body: JSON.stringify(body),
|
|
@@ -4704,10 +4704,10 @@ function writeTextFileAtomically(file, content, operations = {}) {
|
|
|
4704
4704
|
const rename3 = operations.renameSync || renameSync;
|
|
4705
4705
|
const remove = operations.rmSync || rmSync;
|
|
4706
4706
|
const chmod2 = operations.chmodSync || chmodSync;
|
|
4707
|
-
const
|
|
4707
|
+
const stat4 = operations.statSync || statSync;
|
|
4708
4708
|
let mode;
|
|
4709
4709
|
try {
|
|
4710
|
-
mode =
|
|
4710
|
+
mode = stat4(file).mode & 511;
|
|
4711
4711
|
} catch {
|
|
4712
4712
|
}
|
|
4713
4713
|
try {
|
|
@@ -4829,11 +4829,11 @@ function attrValue(attrs, name) {
|
|
|
4829
4829
|
import crypto6 from "node:crypto";
|
|
4830
4830
|
import { EventEmitter } from "node:events";
|
|
4831
4831
|
import { existsSync as existsSync2 } from "node:fs";
|
|
4832
|
-
import { readFile as readFile5, realpath as
|
|
4832
|
+
import { readFile as readFile5, realpath as realpath4 } from "node:fs/promises";
|
|
4833
4833
|
import { createServer, get as httpGet } from "node:http";
|
|
4834
4834
|
import { isIP as isIP3 } from "node:net";
|
|
4835
4835
|
import { homedir } from "node:os";
|
|
4836
|
-
import
|
|
4836
|
+
import path8 from "node:path";
|
|
4837
4837
|
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
4838
4838
|
import chokidar from "chokidar";
|
|
4839
4839
|
import express from "express";
|
|
@@ -5925,15 +5925,15 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
|
|
|
5925
5925
|
return node.parentElement || document.body;
|
|
5926
5926
|
}
|
|
5927
5927
|
function nodePath(node, root) {
|
|
5928
|
-
const
|
|
5928
|
+
const path10 = [];
|
|
5929
5929
|
let current = node;
|
|
5930
5930
|
while (current && current !== root) {
|
|
5931
5931
|
const parentNode = current.parentNode;
|
|
5932
5932
|
if (!parentNode) break;
|
|
5933
|
-
|
|
5933
|
+
path10.unshift([...parentNode.childNodes].indexOf(current));
|
|
5934
5934
|
current = parentNode;
|
|
5935
5935
|
}
|
|
5936
|
-
return
|
|
5936
|
+
return path10;
|
|
5937
5937
|
}
|
|
5938
5938
|
function rangeBoundary(node, offset) {
|
|
5939
5939
|
const el = closestElement(node);
|
|
@@ -7146,6 +7146,11 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
|
|
|
7146
7146
|
(event) => {
|
|
7147
7147
|
if (!annotationMode || isLavishUi(event.target) || isLavishAction(event.target) || isInteractiveControl(event.target))
|
|
7148
7148
|
return;
|
|
7149
|
+
if ((event.ctrlKey || event.metaKey) && /** @type {Element} */
|
|
7150
|
+
event.target?.closest?.("a[href]")) {
|
|
7151
|
+
ignoreNextClick = false;
|
|
7152
|
+
return;
|
|
7153
|
+
}
|
|
7149
7154
|
event.preventDefault();
|
|
7150
7155
|
event.stopPropagation();
|
|
7151
7156
|
if (ignoreNextClick) {
|
|
@@ -8276,6 +8281,62 @@ function serializeChatSync(session) {
|
|
|
8276
8281
|
chat_revision: Number.isSafeInteger(revision) && revision >= 0 ? revision : 0
|
|
8277
8282
|
};
|
|
8278
8283
|
}
|
|
8284
|
+
var RECEIPT_NONE = "none";
|
|
8285
|
+
function isUserEntry(entry) {
|
|
8286
|
+
return Boolean(entry) && typeof entry === "object" && entry.role === "user" && entry.receipt !== RECEIPT_NONE;
|
|
8287
|
+
}
|
|
8288
|
+
function markPreReceipt(chat) {
|
|
8289
|
+
let changed = false;
|
|
8290
|
+
for (const entry of Array.isArray(chat) ? chat : []) {
|
|
8291
|
+
if (!entry || typeof entry !== "object" || entry.role !== "user" || entry.receipt === RECEIPT_NONE) continue;
|
|
8292
|
+
entry.receipt = RECEIPT_NONE;
|
|
8293
|
+
changed = true;
|
|
8294
|
+
}
|
|
8295
|
+
return changed;
|
|
8296
|
+
}
|
|
8297
|
+
function stampDelivered(chat, seq, at) {
|
|
8298
|
+
let changed = false;
|
|
8299
|
+
for (const entry of Array.isArray(chat) ? chat : []) {
|
|
8300
|
+
if (!isUserEntry(entry) || entry.delivered_at) continue;
|
|
8301
|
+
entry.delivered_at = String(at);
|
|
8302
|
+
entry.delivered_seq = seq;
|
|
8303
|
+
changed = true;
|
|
8304
|
+
}
|
|
8305
|
+
return changed;
|
|
8306
|
+
}
|
|
8307
|
+
function unstampDelivery(chat, seq) {
|
|
8308
|
+
let changed = false;
|
|
8309
|
+
for (const entry of Array.isArray(chat) ? chat : []) {
|
|
8310
|
+
if (!isUserEntry(entry) || entry.delivered_seq !== seq) continue;
|
|
8311
|
+
delete entry.delivered_at;
|
|
8312
|
+
delete entry.delivered_seq;
|
|
8313
|
+
delete entry.working_at;
|
|
8314
|
+
delete entry.done_at;
|
|
8315
|
+
changed = true;
|
|
8316
|
+
}
|
|
8317
|
+
return changed;
|
|
8318
|
+
}
|
|
8319
|
+
function isOpenDelivery(entry, closedSeq) {
|
|
8320
|
+
return isUserEntry(entry) && Boolean(entry.delivered_at) && !entry.done_at && entry.delivered_seq > closedSeq;
|
|
8321
|
+
}
|
|
8322
|
+
function stampWorking(chat, at, closedSeq = 0) {
|
|
8323
|
+
let changed = false;
|
|
8324
|
+
for (const entry of Array.isArray(chat) ? chat : []) {
|
|
8325
|
+
if (!isOpenDelivery(entry, closedSeq) || entry.working_at) continue;
|
|
8326
|
+
entry.working_at = String(at);
|
|
8327
|
+
changed = true;
|
|
8328
|
+
}
|
|
8329
|
+
return changed;
|
|
8330
|
+
}
|
|
8331
|
+
function stampDone(chat, at, closedSeq = 0) {
|
|
8332
|
+
let changed = false;
|
|
8333
|
+
for (const entry of Array.isArray(chat) ? chat : []) {
|
|
8334
|
+
if (!isOpenDelivery(entry, closedSeq)) continue;
|
|
8335
|
+
entry.done_at = String(at);
|
|
8336
|
+
changed = true;
|
|
8337
|
+
}
|
|
8338
|
+
return changed;
|
|
8339
|
+
}
|
|
8279
8340
|
|
|
8280
8341
|
// src/server-log.js
|
|
8281
8342
|
function formatServerLogLine(line, now = /* @__PURE__ */ new Date()) {
|
|
@@ -8398,10 +8459,53 @@ function isIncompleteRunningTailscaleStatus(raw) {
|
|
|
8398
8459
|
return addresses.some((address) => typeof address === "string" && isIP2(address.trim()) === 4);
|
|
8399
8460
|
}
|
|
8400
8461
|
|
|
8462
|
+
// src/artifact-path.js
|
|
8463
|
+
import { realpath as realpath2, stat as stat2 } from "node:fs/promises";
|
|
8464
|
+
import path5 from "node:path";
|
|
8465
|
+
var ARTIFACT_HTML_ERROR = "Lavish Editor expects an HTML file";
|
|
8466
|
+
function isHtmlPath(file) {
|
|
8467
|
+
const lower = String(file || "").toLowerCase();
|
|
8468
|
+
return lower.endsWith(".html") || lower.endsWith(".htm");
|
|
8469
|
+
}
|
|
8470
|
+
function artifactTreeRoot(file) {
|
|
8471
|
+
return path5.dirname(file);
|
|
8472
|
+
}
|
|
8473
|
+
var ArtifactPathError = class extends Error {
|
|
8474
|
+
/** @type {"VALIDATION_ERROR"} */
|
|
8475
|
+
code = "VALIDATION_ERROR";
|
|
8476
|
+
/** @type {400} */
|
|
8477
|
+
statusCode = 400;
|
|
8478
|
+
/**
|
|
8479
|
+
* @param {string} [message]
|
|
8480
|
+
*/
|
|
8481
|
+
constructor(message = ARTIFACT_HTML_ERROR) {
|
|
8482
|
+
super(message);
|
|
8483
|
+
this.name = "ArtifactPathError";
|
|
8484
|
+
}
|
|
8485
|
+
};
|
|
8486
|
+
function isArtifactPathError(error) {
|
|
8487
|
+
return error instanceof Error && error.name === "ArtifactPathError";
|
|
8488
|
+
}
|
|
8489
|
+
async function resolveAllowedArtifactFile(file) {
|
|
8490
|
+
const requested = typeof file === "string" ? file : "";
|
|
8491
|
+
if (!isHtmlPath(requested)) {
|
|
8492
|
+
throw new ArtifactPathError();
|
|
8493
|
+
}
|
|
8494
|
+
const canonical = await realpath2(path5.resolve(requested));
|
|
8495
|
+
if (!isHtmlPath(canonical)) {
|
|
8496
|
+
throw new ArtifactPathError();
|
|
8497
|
+
}
|
|
8498
|
+
const info = await stat2(canonical);
|
|
8499
|
+
if (!info.isFile()) {
|
|
8500
|
+
throw new ArtifactPathError();
|
|
8501
|
+
}
|
|
8502
|
+
return canonical;
|
|
8503
|
+
}
|
|
8504
|
+
|
|
8401
8505
|
// src/session-store.js
|
|
8402
8506
|
import crypto4 from "node:crypto";
|
|
8403
|
-
import { readFile as readFile3, realpath as
|
|
8404
|
-
import
|
|
8507
|
+
import { readFile as readFile3, realpath as realpath3, writeFile as writeFile2 } from "node:fs/promises";
|
|
8508
|
+
import path6 from "node:path";
|
|
8405
8509
|
|
|
8406
8510
|
// src/async-mutex.js
|
|
8407
8511
|
var AsyncMutex = class {
|
|
@@ -8504,6 +8608,14 @@ var SessionStore = class {
|
|
|
8504
8608
|
// Compact prompt_id acks for bubbles evicted by the stored-chat byte bound. Reopening
|
|
8505
8609
|
// must keep them: they are the settlement/dedup source once the visible entry is gone.
|
|
8506
8610
|
chat_ack_ids: Array.isArray(existing.chat_ack_ids) ? existing.chat_ack_ids : [],
|
|
8611
|
+
// Counts the takes that stamped Seen on chat entries (docs/delivery-acks.md). Reset on reopen
|
|
8612
|
+
// and a later restore could un-stamp an older take's entries, so it is carried like chat.
|
|
8613
|
+
delivery_seq: normalizeRevision(existing.delivery_seq),
|
|
8614
|
+
// The last take of the rounds that have ended. Reopening starts a new round: a note an ended
|
|
8615
|
+
// round left at Seen stays Seen, because a later round's edit or reply is not about it.
|
|
8616
|
+
closed_delivery_seq: normalizeRevision(
|
|
8617
|
+
existing.status === "ended" ? existing.delivery_seq : existing.closed_delivery_seq
|
|
8618
|
+
),
|
|
8507
8619
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
8508
8620
|
};
|
|
8509
8621
|
state.sessions[key] = session;
|
|
@@ -8558,7 +8670,11 @@ var SessionStore = class {
|
|
|
8558
8670
|
else delete prompt.attachments;
|
|
8559
8671
|
}
|
|
8560
8672
|
}
|
|
8673
|
+
const deliverySeq = restoring ? normalizeRevision(payload.delivery_seq) : 0;
|
|
8674
|
+
const unstamped = deliverySeq > 0 && unstampDelivery(session.chat, deliverySeq);
|
|
8675
|
+
if (unstamped) session.chat_revision = normalizeRevision(session.chat_revision) + 1;
|
|
8561
8676
|
if (rejected.length) {
|
|
8677
|
+
if (unstamped) await this.writeState(state);
|
|
8562
8678
|
return {
|
|
8563
8679
|
rejected: rejected.slice(0, MAX_REPORTED_ATTACHMENT_REJECTIONS),
|
|
8564
8680
|
caps: {
|
|
@@ -8885,12 +9001,14 @@ var SessionStore = class {
|
|
|
8885
9001
|
if (prompts.length === 0 && artifactFailures.length === 0) {
|
|
8886
9002
|
return alreadyEnded ? { status: "ended", ended_by: session.ended_by } : { status: "waiting" };
|
|
8887
9003
|
}
|
|
9004
|
+
const deliverySeq = normalizeRevision(session.delivery_seq) + 1;
|
|
8888
9005
|
const result = {
|
|
8889
9006
|
status: "feedback",
|
|
8890
9007
|
dom_snapshot: session.dom_snapshot || "",
|
|
8891
9008
|
prompts,
|
|
8892
9009
|
...artifactFailures.length > 0 ? { artifact_failures: artifactFailures } : {},
|
|
8893
|
-
...alreadyEnded ? { session_ended: true, ended_by: session.ended_by } : {}
|
|
9010
|
+
...alreadyEnded ? { session_ended: true, ended_by: session.ended_by } : {},
|
|
9011
|
+
...prompts.length > 0 ? { delivery_seq: deliverySeq } : {}
|
|
8894
9012
|
};
|
|
8895
9013
|
const deliveredNow = Date.now();
|
|
8896
9014
|
const deliveredIds = /* @__PURE__ */ new Set();
|
|
@@ -8905,6 +9023,12 @@ var SessionStore = class {
|
|
|
8905
9023
|
const current = [...deliveredIds].map((id) => ({ id, at: deliveredNow }));
|
|
8906
9024
|
const historyRoom = Math.max(0, MAX_DELIVERED_ATTACHMENTS - current.length);
|
|
8907
9025
|
session.delivered_attachments = [...carried.slice(-historyRoom), ...current];
|
|
9026
|
+
if (prompts.length > 0) {
|
|
9027
|
+
session.delivery_seq = deliverySeq;
|
|
9028
|
+
if (stampDelivered(session.chat, deliverySeq, new Date(deliveredNow).toISOString())) {
|
|
9029
|
+
session.chat_revision = normalizeRevision(session.chat_revision) + 1;
|
|
9030
|
+
}
|
|
9031
|
+
}
|
|
8908
9032
|
session.prompts = [];
|
|
8909
9033
|
session.artifact_failures = [];
|
|
8910
9034
|
session.pending_prompts = 0;
|
|
@@ -8945,6 +9069,7 @@ var SessionStore = class {
|
|
|
8945
9069
|
}
|
|
8946
9070
|
if (requireOpen && session.status === "ended") return session;
|
|
8947
9071
|
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
9072
|
+
stampDone(session.chat, at, normalizeRevision(session.closed_delivery_seq));
|
|
8948
9073
|
session.chat = [...session.chat || [], { role: "agent", text: String(text || ""), at }];
|
|
8949
9074
|
applyTranscriptBound(session);
|
|
8950
9075
|
session.chat_revision = normalizeRevision(session.chat_revision) + 1;
|
|
@@ -8953,6 +9078,22 @@ var SessionStore = class {
|
|
|
8953
9078
|
return session;
|
|
8954
9079
|
});
|
|
8955
9080
|
}
|
|
9081
|
+
// Working (docs/delivery-acks.md): the artifact changed after a delivery, so the agent is acting on
|
|
9082
|
+
// what it was handed. Returns the session when a stamp landed and null when there was nothing to
|
|
9083
|
+
// stamp, in which case state.json is not rewritten - the watcher fires on every save.
|
|
9084
|
+
async markWorking(key) {
|
|
9085
|
+
return this.runExclusive(async () => {
|
|
9086
|
+
const state = await this.readState();
|
|
9087
|
+
const session = state.sessions[key];
|
|
9088
|
+
if (!session) return null;
|
|
9089
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
9090
|
+
if (!stampWorking(session.chat, at, normalizeRevision(session.closed_delivery_seq))) return null;
|
|
9091
|
+
session.chat_revision = normalizeRevision(session.chat_revision) + 1;
|
|
9092
|
+
session.updated_at = at;
|
|
9093
|
+
await this.writeState(state);
|
|
9094
|
+
return session;
|
|
9095
|
+
});
|
|
9096
|
+
}
|
|
8956
9097
|
/**
|
|
8957
9098
|
* @template T
|
|
8958
9099
|
* @param {() => Promise<T>} operation
|
|
@@ -8995,7 +9136,13 @@ var SessionStore = class {
|
|
|
8995
9136
|
const state = { sessions: parsed.sessions || {} };
|
|
8996
9137
|
let changed = false;
|
|
8997
9138
|
for (const session of Object.values(state.sessions)) {
|
|
8998
|
-
if (!session || typeof session !== "object"
|
|
9139
|
+
if (!session || typeof session !== "object") continue;
|
|
9140
|
+
if (!("delivery_seq" in session)) {
|
|
9141
|
+
session.delivery_seq = 0;
|
|
9142
|
+
markPreReceipt(session.chat);
|
|
9143
|
+
changed = true;
|
|
9144
|
+
}
|
|
9145
|
+
if (!applyTranscriptBound(session)) continue;
|
|
8999
9146
|
session.chat_revision = normalizeRevision(session.chat_revision) + 1;
|
|
9000
9147
|
changed = true;
|
|
9001
9148
|
}
|
|
@@ -9014,8 +9161,8 @@ var SessionStore = class {
|
|
|
9014
9161
|
}
|
|
9015
9162
|
};
|
|
9016
9163
|
async function canonicalFile(file) {
|
|
9017
|
-
const absolute =
|
|
9018
|
-
return
|
|
9164
|
+
const absolute = path6.resolve(file);
|
|
9165
|
+
return realpath3(absolute);
|
|
9019
9166
|
}
|
|
9020
9167
|
function sessionKey(file) {
|
|
9021
9168
|
return crypto4.createHash("sha256").update(file).digest("hex").slice(0, 16);
|
|
@@ -9255,8 +9402,8 @@ function generateSharePassword() {
|
|
|
9255
9402
|
|
|
9256
9403
|
// src/attachment-store.js
|
|
9257
9404
|
import crypto5 from "node:crypto";
|
|
9258
|
-
import { chmod, mkdir as mkdir3, readdir, readFile as readFile4, rename as rename2, rm as rm2, stat as
|
|
9259
|
-
import
|
|
9405
|
+
import { chmod, mkdir as mkdir3, readdir, readFile as readFile4, rename as rename2, rm as rm2, stat as stat3, utimes, writeFile as writeFile3 } from "node:fs/promises";
|
|
9406
|
+
import path7 from "node:path";
|
|
9260
9407
|
var KEY_RE2 = /^[0-9a-f]{16}$/;
|
|
9261
9408
|
var ID_RE = /^[0-9a-f]{64}\.(png|jpg|webp)$/;
|
|
9262
9409
|
var MIME_BY_EXT = { png: "image/png", jpg: "image/jpeg", webp: "image/webp" };
|
|
@@ -9284,10 +9431,10 @@ function isValidAttachmentId(id) {
|
|
|
9284
9431
|
return ID_RE.test(String(id || ""));
|
|
9285
9432
|
}
|
|
9286
9433
|
function attachmentsDir(stateDir2, key) {
|
|
9287
|
-
return
|
|
9434
|
+
return path7.join(stateDir2, "attachments", String(key));
|
|
9288
9435
|
}
|
|
9289
9436
|
function attachmentFile(stateDir2, key, id) {
|
|
9290
|
-
return
|
|
9437
|
+
return path7.join(attachmentsDir(stateDir2, key), id);
|
|
9291
9438
|
}
|
|
9292
9439
|
function resolveAttachmentConfig(env = process.env) {
|
|
9293
9440
|
const maxDiskBytes = diskCapEnv(env.LAVISH_AXI_MAX_ATTACHMENT_DISK_MB);
|
|
@@ -9422,7 +9569,7 @@ function buildMetadata(id, file, mime, bytes, dims) {
|
|
|
9422
9569
|
}
|
|
9423
9570
|
async function pathExists(file) {
|
|
9424
9571
|
try {
|
|
9425
|
-
await
|
|
9572
|
+
await stat3(file);
|
|
9426
9573
|
return true;
|
|
9427
9574
|
} catch (error) {
|
|
9428
9575
|
if (error && error.code === "ENOENT") return false;
|
|
@@ -9462,7 +9609,7 @@ async function writeFileAtomically2(file, content) {
|
|
|
9462
9609
|
}
|
|
9463
9610
|
}
|
|
9464
9611
|
async function ensureAttachmentDir(stateDir2, key) {
|
|
9465
|
-
const root =
|
|
9612
|
+
const root = path7.join(stateDir2, "attachments");
|
|
9466
9613
|
const dir = attachmentsDir(stateDir2, key);
|
|
9467
9614
|
await mkdir3(dir, { recursive: true, mode: ATTACHMENT_DIR_MODE });
|
|
9468
9615
|
for (const target of [root, dir]) {
|
|
@@ -9543,7 +9690,7 @@ async function resolveAttachment(stateDir2, key, id) {
|
|
|
9543
9690
|
const file = attachmentFile(stateDir2, key, id);
|
|
9544
9691
|
let info;
|
|
9545
9692
|
try {
|
|
9546
|
-
info = await
|
|
9693
|
+
info = await stat3(file);
|
|
9547
9694
|
} catch (error) {
|
|
9548
9695
|
if (error && error.code === "ENOENT") return null;
|
|
9549
9696
|
throw error;
|
|
@@ -9557,7 +9704,7 @@ async function statAttachmentForServe(stateDir2, key, id) {
|
|
|
9557
9704
|
if (!isValidAttachmentKey(key) || !isValidAttachmentId(id)) return null;
|
|
9558
9705
|
const file = attachmentFile(stateDir2, key, id);
|
|
9559
9706
|
try {
|
|
9560
|
-
const info = await
|
|
9707
|
+
const info = await stat3(file);
|
|
9561
9708
|
if (!info.isFile()) return null;
|
|
9562
9709
|
} catch (error) {
|
|
9563
9710
|
if (error && error.code === "ENOENT") return null;
|
|
@@ -9586,20 +9733,20 @@ async function removeAttachment(stateDir2, key, id) {
|
|
|
9586
9733
|
}
|
|
9587
9734
|
}
|
|
9588
9735
|
async function listAttachments(stateDir2) {
|
|
9589
|
-
const root =
|
|
9736
|
+
const root = path7.join(stateDir2, "attachments");
|
|
9590
9737
|
const sessionDirs = await readdirSafe(root);
|
|
9591
9738
|
const files = [];
|
|
9592
9739
|
for (const dirent of sessionDirs) {
|
|
9593
9740
|
if (!dirent.isDirectory() || !isValidAttachmentKey(dirent.name)) continue;
|
|
9594
|
-
const dir =
|
|
9741
|
+
const dir = path7.join(root, dirent.name);
|
|
9595
9742
|
for (const entry of await readdirSafe(dir)) {
|
|
9596
9743
|
if (!entry.isFile() || !isValidAttachmentId(entry.name)) continue;
|
|
9597
|
-
const filePath =
|
|
9744
|
+
const filePath = path7.join(dir, entry.name);
|
|
9598
9745
|
try {
|
|
9599
|
-
const info = await
|
|
9746
|
+
const info = await stat3(filePath);
|
|
9600
9747
|
let sidecarBytes = 0;
|
|
9601
9748
|
try {
|
|
9602
|
-
sidecarBytes = (await
|
|
9749
|
+
sidecarBytes = (await stat3(sidecarPath(filePath))).size;
|
|
9603
9750
|
} catch {
|
|
9604
9751
|
}
|
|
9605
9752
|
files.push({
|
|
@@ -9676,19 +9823,19 @@ async function sweepAttachments(stateDir2, options = {}) {
|
|
|
9676
9823
|
const orphans = await reapOrphanFiles(stateDir2, now);
|
|
9677
9824
|
deleted += orphans.deleted;
|
|
9678
9825
|
freedBytes += orphans.freedBytes;
|
|
9679
|
-
await pruneEmptyDirs(
|
|
9826
|
+
await pruneEmptyDirs(path7.join(stateDir2, "attachments"));
|
|
9680
9827
|
return { deleted, freedBytes };
|
|
9681
9828
|
}
|
|
9682
9829
|
async function committedChargedBytes(stateDir2) {
|
|
9683
|
-
const root =
|
|
9830
|
+
const root = path7.join(stateDir2, "attachments");
|
|
9684
9831
|
let total = 0;
|
|
9685
9832
|
for (const dirent of await readdirSafe(root)) {
|
|
9686
9833
|
if (!dirent.isDirectory() || !isValidAttachmentKey(dirent.name)) continue;
|
|
9687
|
-
const dir =
|
|
9834
|
+
const dir = path7.join(root, dirent.name);
|
|
9688
9835
|
for (const entry of await readdirSafe(dir)) {
|
|
9689
9836
|
if (!entry.isFile()) continue;
|
|
9690
9837
|
try {
|
|
9691
|
-
total += allocatedBytes((await
|
|
9838
|
+
total += allocatedBytes((await stat3(path7.join(dir, entry.name))).size);
|
|
9692
9839
|
} catch {
|
|
9693
9840
|
}
|
|
9694
9841
|
}
|
|
@@ -9696,28 +9843,28 @@ async function committedChargedBytes(stateDir2) {
|
|
|
9696
9843
|
return total;
|
|
9697
9844
|
}
|
|
9698
9845
|
async function reapOrphanFiles(stateDir2, now) {
|
|
9699
|
-
const root =
|
|
9846
|
+
const root = path7.join(stateDir2, "attachments");
|
|
9700
9847
|
let deleted = 0;
|
|
9701
9848
|
let freedBytes = 0;
|
|
9702
9849
|
for (const dirent of await readdirSafe(root)) {
|
|
9703
9850
|
if (!dirent.isDirectory() || !isValidAttachmentKey(dirent.name)) continue;
|
|
9704
|
-
const dir =
|
|
9851
|
+
const dir = path7.join(root, dirent.name);
|
|
9705
9852
|
const entries = await readdirSafe(dir);
|
|
9706
9853
|
const present = new Set(entries.filter((e) => e.isFile()).map((e) => e.name));
|
|
9707
9854
|
for (const entry of entries) {
|
|
9708
9855
|
if (!entry.isFile()) continue;
|
|
9709
|
-
const filePath =
|
|
9856
|
+
const filePath = path7.join(dir, entry.name);
|
|
9710
9857
|
const sidecarMatch = SIDECAR_ORPHAN_RE.exec(entry.name);
|
|
9711
9858
|
try {
|
|
9712
9859
|
if (TEMP_FILE_RE.test(entry.name)) {
|
|
9713
|
-
const info = await
|
|
9860
|
+
const info = await stat3(filePath);
|
|
9714
9861
|
if (now - info.mtimeMs <= ATTACHMENT_TEMP_GRACE_MS) continue;
|
|
9715
9862
|
if (await removeFile(filePath)) {
|
|
9716
9863
|
deleted += 1;
|
|
9717
9864
|
freedBytes += info.size;
|
|
9718
9865
|
}
|
|
9719
9866
|
} else if (sidecarMatch && !present.has(sidecarMatch[1])) {
|
|
9720
|
-
const info = await
|
|
9867
|
+
const info = await stat3(filePath);
|
|
9721
9868
|
if (await removeFile(filePath)) {
|
|
9722
9869
|
deleted += 1;
|
|
9723
9870
|
freedBytes += info.size;
|
|
@@ -9750,7 +9897,7 @@ async function removeFile(file) {
|
|
|
9750
9897
|
async function pruneEmptyDirs(root) {
|
|
9751
9898
|
for (const dirent of await readdirSafe(root)) {
|
|
9752
9899
|
if (!dirent.isDirectory()) continue;
|
|
9753
|
-
const dir =
|
|
9900
|
+
const dir = path7.join(root, dirent.name);
|
|
9754
9901
|
try {
|
|
9755
9902
|
if ((await readdir(dir)).length === 0) await rm2(dir, { recursive: true, force: true });
|
|
9756
9903
|
} catch {
|
|
@@ -9950,7 +10097,12 @@ async function serve({
|
|
|
9950
10097
|
if (clientKey === key) client.sendEvent(type, data);
|
|
9951
10098
|
}
|
|
9952
10099
|
}
|
|
9953
|
-
events.on("reload", (key) =>
|
|
10100
|
+
events.on("reload", (key) => {
|
|
10101
|
+
broadcastLiveEvent("reload", key);
|
|
10102
|
+
store.markWorking(key).then((session) => {
|
|
10103
|
+
if (session) events.emit("chat-sync", key, session);
|
|
10104
|
+
}).catch((error) => writeLog(`[lavish] working stamp after reload failed: ${error?.message || error}`));
|
|
10105
|
+
});
|
|
9954
10106
|
events.on("agent-reply", (key, entry) => broadcastLiveEvent("agent-reply", key, entry));
|
|
9955
10107
|
events.on("chat-sync", (key, session) => broadcastLiveEvent("chat-sync", key, serializeChatSync(session)));
|
|
9956
10108
|
events.on(
|
|
@@ -10064,6 +10216,21 @@ async function serve({
|
|
|
10064
10216
|
function finishFeedbackDelivery(key, result) {
|
|
10065
10217
|
if (result.status !== "feedback") return;
|
|
10066
10218
|
markFeedbackDelivered(key, activePolls, deliveredFeedback, events);
|
|
10219
|
+
void publishChatSync(key);
|
|
10220
|
+
}
|
|
10221
|
+
async function publishChatSync(key) {
|
|
10222
|
+
try {
|
|
10223
|
+
const session = await store.findByKey(key);
|
|
10224
|
+
if (session) events.emit("chat-sync", key, session);
|
|
10225
|
+
} catch (error) {
|
|
10226
|
+
writeLog(`[lavish] chat-sync after delivery failed: ${error?.message || error}`);
|
|
10227
|
+
}
|
|
10228
|
+
}
|
|
10229
|
+
function agentFacingFeedback(result) {
|
|
10230
|
+
if (!result || result.status !== "feedback" || !("delivery_seq" in result)) return result;
|
|
10231
|
+
const rest = { ...result };
|
|
10232
|
+
delete rest.delivery_seq;
|
|
10233
|
+
return rest;
|
|
10067
10234
|
}
|
|
10068
10235
|
async function restoreClosedFeedback(key, result) {
|
|
10069
10236
|
if (result.status !== "feedback") return;
|
|
@@ -10076,7 +10243,9 @@ async function serve({
|
|
|
10076
10243
|
{
|
|
10077
10244
|
dom_snapshot: result.dom_snapshot || "",
|
|
10078
10245
|
prompts,
|
|
10079
|
-
...Array.isArray(result.artifact_failures) ? { artifact_failures: result.artifact_failures } : {}
|
|
10246
|
+
...Array.isArray(result.artifact_failures) ? { artifact_failures: result.artifact_failures } : {},
|
|
10247
|
+
// Lets the store take this take's Seen stamps off again (docs/delivery-acks.md).
|
|
10248
|
+
...Number.isInteger(result.delivery_seq) ? { delivery_seq: result.delivery_seq } : {}
|
|
10080
10249
|
},
|
|
10081
10250
|
{
|
|
10082
10251
|
restore: true,
|
|
@@ -10099,14 +10268,15 @@ async function serve({
|
|
|
10099
10268
|
`[lavish] closed poll feedback restore failed; the batch was lost: ${restoreError?.message || restoreError}`
|
|
10100
10269
|
);
|
|
10101
10270
|
} else if (persistedNothing) {
|
|
10102
|
-
writeLog("[lavish] closed poll feedback restore was refused; nothing was
|
|
10271
|
+
writeLog("[lavish] closed poll feedback restore was refused; nothing was re-queued and the batch was lost");
|
|
10103
10272
|
} else if (!restoredPrompts || JSON.stringify(restoredPrompts) !== JSON.stringify(prompts) || !failuresRestored) {
|
|
10104
10273
|
writeLog("[lavish] closed poll feedback restore was incomplete; delivery was not marked");
|
|
10105
10274
|
}
|
|
10106
10275
|
const pendingAfterRestore = Array.isArray(restoredPrompts) && restoredPrompts.length > 0 || Array.isArray(restoredFailures) && restoredFailures.length > 0;
|
|
10107
10276
|
if (pendingAfterRestore) events.emit("feedback", key);
|
|
10277
|
+
await publishChatSync(key);
|
|
10108
10278
|
}
|
|
10109
|
-
const whiteboardStateRoot =
|
|
10279
|
+
const whiteboardStateRoot = path8.dirname(stateFile2);
|
|
10110
10280
|
const allowedHostnames = buildAllowedHostnames({
|
|
10111
10281
|
host: requestedListenHosts[0],
|
|
10112
10282
|
hosts: [...requestedListenHosts, ...listenHosts],
|
|
@@ -10138,6 +10308,11 @@ async function serve({
|
|
|
10138
10308
|
workingUrl: workingUrlFor(req, { includeSessionPath: false })
|
|
10139
10309
|
});
|
|
10140
10310
|
}
|
|
10311
|
+
function refuseNonHtmlArtifact(session, res) {
|
|
10312
|
+
if (isHtmlPath(session.file)) return false;
|
|
10313
|
+
res.status(403).send("Forbidden");
|
|
10314
|
+
return true;
|
|
10315
|
+
}
|
|
10141
10316
|
if (!allowAnyHostname) {
|
|
10142
10317
|
app.use((req, res, next) => {
|
|
10143
10318
|
const requestHost = { host: req.headers.host, forwardedHost: req.headers["x-forwarded-host"] };
|
|
@@ -10168,7 +10343,7 @@ async function serve({
|
|
|
10168
10343
|
next();
|
|
10169
10344
|
});
|
|
10170
10345
|
const attachmentConfig = resolveAttachmentConfig();
|
|
10171
|
-
const attachmentStateRoot =
|
|
10346
|
+
const attachmentStateRoot = path8.dirname(stateFile2);
|
|
10172
10347
|
const defaultJsonParser = express.json({ limit: "2mb" });
|
|
10173
10348
|
const whiteboardJsonParser = express.json({ limit: "20mb" });
|
|
10174
10349
|
app.use((req, res, next) => {
|
|
@@ -10191,7 +10366,7 @@ async function serve({
|
|
|
10191
10366
|
app: "lavish-axi",
|
|
10192
10367
|
version,
|
|
10193
10368
|
state_id: serverStateId,
|
|
10194
|
-
state_dir:
|
|
10369
|
+
state_dir: path8.dirname(stateFile2),
|
|
10195
10370
|
// Where this process is listening and every address it was asked to serve (bound or still
|
|
10196
10371
|
// retrying). The CLI uses both to find one daemon per port whatever host it was configured
|
|
10197
10372
|
// with, and to tell a same-port daemon at another address apart from this one.
|
|
@@ -10219,7 +10394,7 @@ async function serve({
|
|
|
10219
10394
|
});
|
|
10220
10395
|
app.post("/api/sessions", async (req, res, next) => {
|
|
10221
10396
|
try {
|
|
10222
|
-
const file = await
|
|
10397
|
+
const file = await resolveAllowedArtifactFile(req.body?.file);
|
|
10223
10398
|
const key = sessionKey(file);
|
|
10224
10399
|
const reopen = Boolean(req.body.reopen);
|
|
10225
10400
|
const existing = await store.findByKey(key);
|
|
@@ -10251,6 +10426,14 @@ async function serve({
|
|
|
10251
10426
|
...networkWarningField()
|
|
10252
10427
|
});
|
|
10253
10428
|
} catch (error) {
|
|
10429
|
+
if (isArtifactPathError(error)) {
|
|
10430
|
+
res.status(error.statusCode).json({
|
|
10431
|
+
status: "error",
|
|
10432
|
+
code: error.code,
|
|
10433
|
+
error: error.message
|
|
10434
|
+
});
|
|
10435
|
+
return;
|
|
10436
|
+
}
|
|
10254
10437
|
next(error);
|
|
10255
10438
|
}
|
|
10256
10439
|
});
|
|
@@ -10374,7 +10557,7 @@ async function serve({
|
|
|
10374
10557
|
releasePollListener(holder, activePolls, deliveredFeedback, events);
|
|
10375
10558
|
if (immediate.session_ended) clearFeedbackDelivery(key, activePolls, deliveredFeedback, events);
|
|
10376
10559
|
detachRequestClose();
|
|
10377
|
-
res.json(immediate);
|
|
10560
|
+
res.json(agentFacingFeedback(immediate));
|
|
10378
10561
|
return;
|
|
10379
10562
|
}
|
|
10380
10563
|
if (holder.replaced) {
|
|
@@ -10453,10 +10636,11 @@ async function serve({
|
|
|
10453
10636
|
return;
|
|
10454
10637
|
}
|
|
10455
10638
|
finishFeedbackDelivery(key, responseResult);
|
|
10639
|
+
const agentResponse = agentFacingFeedback(responseResult);
|
|
10456
10640
|
if (streamHeartbeat) {
|
|
10457
|
-
res.end(JSON.stringify(
|
|
10641
|
+
res.end(JSON.stringify(agentResponse));
|
|
10458
10642
|
} else {
|
|
10459
|
-
res.json(
|
|
10643
|
+
res.json(agentResponse);
|
|
10460
10644
|
}
|
|
10461
10645
|
} finally {
|
|
10462
10646
|
cleanup();
|
|
@@ -10678,8 +10862,9 @@ async function serve({
|
|
|
10678
10862
|
res.status(404).json({ error: "session not found" });
|
|
10679
10863
|
return;
|
|
10680
10864
|
}
|
|
10865
|
+
if (refuseNonHtmlArtifact(session, res)) return;
|
|
10681
10866
|
const source = await readFile5(session.file, "utf8");
|
|
10682
|
-
const root =
|
|
10867
|
+
const root = path8.dirname(session.file);
|
|
10683
10868
|
const { html, warnings } = await buildSelfContainedHtml(source, {
|
|
10684
10869
|
baseDir: root,
|
|
10685
10870
|
confineDir: root,
|
|
@@ -10706,11 +10891,12 @@ async function serve({
|
|
|
10706
10891
|
res.status(404).json({ error: "session not found" });
|
|
10707
10892
|
return;
|
|
10708
10893
|
}
|
|
10894
|
+
if (refuseNonHtmlArtifact(session, res)) return;
|
|
10709
10895
|
const body = req.body || {};
|
|
10710
10896
|
const generatePassword = body.generate_password === true;
|
|
10711
10897
|
const password = generatePassword ? generateSharePassword() : optionalBodyString(body.password);
|
|
10712
10898
|
const source = await readFile5(session.file, "utf8");
|
|
10713
|
-
const root =
|
|
10899
|
+
const root = path8.dirname(session.file);
|
|
10714
10900
|
const { html, warnings } = await buildSelfContainedHtml(source, {
|
|
10715
10901
|
baseDir: root,
|
|
10716
10902
|
confineDir: root,
|
|
@@ -10781,6 +10967,7 @@ async function serve({
|
|
|
10781
10967
|
return;
|
|
10782
10968
|
}
|
|
10783
10969
|
const session = chromeLoad.session;
|
|
10970
|
+
if (refuseNonHtmlArtifact(session, res)) return;
|
|
10784
10971
|
await watchSession(session, watchers, events, logEvent, reloadDebounceMs);
|
|
10785
10972
|
const artifactHtml = await readFile5(session.file, "utf8").catch(() => "");
|
|
10786
10973
|
const { faviconTag, title } = extractArtifactHead(artifactHtml);
|
|
@@ -10858,6 +11045,7 @@ async function serve({
|
|
|
10858
11045
|
sendSessionNotFound(req, res);
|
|
10859
11046
|
return;
|
|
10860
11047
|
}
|
|
11048
|
+
if (refuseNonHtmlArtifact(beforeRead.session, res)) return;
|
|
10861
11049
|
if (!beforeRead.valid) {
|
|
10862
11050
|
res.status(409).type("html").send(
|
|
10863
11051
|
"<!doctype html><title>Artifact load expired</title><p>This artifact load is no longer current. Reload Lavish to continue.</p>"
|
|
@@ -10887,7 +11075,8 @@ async function serve({
|
|
|
10887
11075
|
sendSessionNotFound(req, res);
|
|
10888
11076
|
return;
|
|
10889
11077
|
}
|
|
10890
|
-
|
|
11078
|
+
if (refuseNonHtmlArtifact(session, res)) return;
|
|
11079
|
+
const root = artifactTreeRoot(session.file);
|
|
10891
11080
|
const file = await resolveArtifactAsset(root, assetPath);
|
|
10892
11081
|
if (!file) {
|
|
10893
11082
|
res.status(403).send("Forbidden");
|
|
@@ -10994,6 +11183,7 @@ data: ${JSON.stringify({ reason: "server-restarted" })}
|
|
|
10994
11183
|
res.status(404).json({ error: "session not found" });
|
|
10995
11184
|
return;
|
|
10996
11185
|
}
|
|
11186
|
+
if (refuseNonHtmlArtifact(session, res)) return;
|
|
10997
11187
|
const html = await readFile5(session.file, "utf8").catch(() => "");
|
|
10998
11188
|
const sources = extractMermaidSources(html).map(({ index, source }) => ({
|
|
10999
11189
|
index,
|
|
@@ -11709,14 +11899,14 @@ function optionalBodyString(value) {
|
|
|
11709
11899
|
return trimmed || void 0;
|
|
11710
11900
|
}
|
|
11711
11901
|
async function resolveArtifactAsset(root, assetPath) {
|
|
11712
|
-
const file =
|
|
11713
|
-
const relative =
|
|
11714
|
-
if (relative.startsWith("..") ||
|
|
11902
|
+
const file = path8.resolve(root, assetPath);
|
|
11903
|
+
const relative = path8.relative(root, file);
|
|
11904
|
+
if (relative.startsWith("..") || path8.isAbsolute(relative)) {
|
|
11715
11905
|
return null;
|
|
11716
11906
|
}
|
|
11717
11907
|
let real;
|
|
11718
11908
|
try {
|
|
11719
|
-
real = await
|
|
11909
|
+
real = await realpath4(file);
|
|
11720
11910
|
} catch (error) {
|
|
11721
11911
|
if (error?.code === "ENOENT" || error?.code === "ENOTDIR") {
|
|
11722
11912
|
return file;
|
|
@@ -11725,12 +11915,12 @@ async function resolveArtifactAsset(root, assetPath) {
|
|
|
11725
11915
|
}
|
|
11726
11916
|
let realRoot;
|
|
11727
11917
|
try {
|
|
11728
|
-
realRoot = await
|
|
11918
|
+
realRoot = await realpath4(root);
|
|
11729
11919
|
} catch {
|
|
11730
|
-
realRoot =
|
|
11920
|
+
realRoot = path8.resolve(root);
|
|
11731
11921
|
}
|
|
11732
|
-
const relativeReal =
|
|
11733
|
-
if (relativeReal === ".." || relativeReal.startsWith(`..${
|
|
11922
|
+
const relativeReal = path8.relative(realRoot, real);
|
|
11923
|
+
if (relativeReal === ".." || relativeReal.startsWith(`..${path8.sep}`) || path8.isAbsolute(relativeReal)) {
|
|
11734
11924
|
return null;
|
|
11735
11925
|
}
|
|
11736
11926
|
return real;
|
|
@@ -11766,7 +11956,7 @@ async function resolveWatchTarget(session) {
|
|
|
11766
11956
|
const html = await readFile5(session.file, "utf8");
|
|
11767
11957
|
if (hasLiveReloadRootOptIn(html)) {
|
|
11768
11958
|
return {
|
|
11769
|
-
path:
|
|
11959
|
+
path: path8.dirname(session.file),
|
|
11770
11960
|
scope: "directory",
|
|
11771
11961
|
options: {
|
|
11772
11962
|
...baseOptions,
|
|
@@ -12220,9 +12410,9 @@ var HttpTelemetryClient = class {
|
|
|
12220
12410
|
if (!trimmed) return;
|
|
12221
12411
|
this.send(trimmed, eventURL(this.app, trimmed), fields);
|
|
12222
12412
|
}
|
|
12223
|
-
pageview(
|
|
12413
|
+
pageview(path10, fields = {}) {
|
|
12224
12414
|
if (this.closed) return;
|
|
12225
|
-
this.send("", normalizePagePath(
|
|
12415
|
+
this.send("", normalizePagePath(path10), fields);
|
|
12226
12416
|
}
|
|
12227
12417
|
async close(timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
|
|
12228
12418
|
this.closed = true;
|
|
@@ -12297,8 +12487,8 @@ function eventURL(app, name) {
|
|
|
12297
12487
|
if (!name) return `app://${app}`;
|
|
12298
12488
|
return `app://${app}/${name.replace(/\./g, "/")}`;
|
|
12299
12489
|
}
|
|
12300
|
-
function normalizePagePath(
|
|
12301
|
-
const trimmed = String(
|
|
12490
|
+
function normalizePagePath(path10) {
|
|
12491
|
+
const trimmed = String(path10 || "").trim();
|
|
12302
12492
|
if (!trimmed) return "/";
|
|
12303
12493
|
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
12304
12494
|
}
|
|
@@ -12340,7 +12530,7 @@ var AGENT_REPLY_INPUT_LIMIT_BYTES = AGENT_REPLY_JSON_LIMIT_BYTES - AGENT_REPLY_J
|
|
|
12340
12530
|
var AGENT_REPLY_LIMIT_LABEL = "2 MB JSON request limit";
|
|
12341
12531
|
var POLL_STATE_HEADER = "lavish-poll-state";
|
|
12342
12532
|
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.";
|
|
12343
|
-
var VERSION = "0.1.
|
|
12533
|
+
var VERSION = "0.1.84";
|
|
12344
12534
|
function detectInvokingAgent(env = process.env) {
|
|
12345
12535
|
return ["CODEX_SANDBOX", "CODEX_THREAD_ID"].some((key) => Object.hasOwn(env, key)) ? "codex" : "generic";
|
|
12346
12536
|
}
|
|
@@ -12818,8 +13008,8 @@ async function exportCommand(args) {
|
|
|
12818
13008
|
}
|
|
12819
13009
|
await assertHtmlFile(file);
|
|
12820
13010
|
const absolute = await canonicalFile(file);
|
|
12821
|
-
const root =
|
|
12822
|
-
const output =
|
|
13011
|
+
const root = path9.dirname(absolute);
|
|
13012
|
+
const output = path9.resolve(flagValue(args, "--out") || path9.join(root, exportFileName(absolute)));
|
|
12823
13013
|
const source = await readFile6(absolute, "utf8");
|
|
12824
13014
|
const { html, warnings } = await buildSelfContainedHtml(source, {
|
|
12825
13015
|
baseDir: root,
|
|
@@ -12874,7 +13064,7 @@ async function shareCommand(args) {
|
|
|
12874
13064
|
}
|
|
12875
13065
|
await assertHtmlFile(request.file);
|
|
12876
13066
|
const absolute = await canonicalFile(request.file);
|
|
12877
|
-
const root =
|
|
13067
|
+
const root = path9.dirname(absolute);
|
|
12878
13068
|
const source = await readFile6(absolute, "utf8");
|
|
12879
13069
|
const { html, warnings } = await buildSelfContainedHtml(source, {
|
|
12880
13070
|
baseDir: root,
|
|
@@ -13297,7 +13487,7 @@ async function setupPluginCommand() {
|
|
|
13297
13487
|
const manifest = readPluginManifest(pluginRoot);
|
|
13298
13488
|
if (!manifest) {
|
|
13299
13489
|
throw new AxiError("No plugin.json found in the lavish-axi package", "SERVER_ERROR", [
|
|
13300
|
-
`Expected a manifest at ${
|
|
13490
|
+
`Expected a manifest at ${path9.join(pluginRoot, "plugin.json")}`,
|
|
13301
13491
|
"Reinstall lavish-axi, or run `npm run build:plugin` when working from a source checkout"
|
|
13302
13492
|
]);
|
|
13303
13493
|
}
|
|
@@ -13321,7 +13511,7 @@ function collapseHome(target) {
|
|
|
13321
13511
|
}
|
|
13322
13512
|
function registerVsCodePlugin(pluginRoot, pluginName) {
|
|
13323
13513
|
const settingsFile = resolveVsCodeSettingsFile(process.env, resolveHookHomeDir());
|
|
13324
|
-
const settingsDir =
|
|
13514
|
+
const settingsDir = path9.dirname(settingsFile);
|
|
13325
13515
|
const hasSettingsFile = existsSync3(settingsFile);
|
|
13326
13516
|
if (!hasSettingsFile && !existsSync3(settingsDir)) {
|
|
13327
13517
|
return { client: "vscode", status: "absent", detail: "no VS Code user configuration found" };
|
|
@@ -13350,7 +13540,7 @@ function registerVsCodePlugin(pluginRoot, pluginName) {
|
|
|
13350
13540
|
return { client: "vscode", status: "registered", detail: collapseHome(settingsFile) };
|
|
13351
13541
|
}
|
|
13352
13542
|
function registerCursorPlugin(pluginRoot, pluginName) {
|
|
13353
|
-
const cursorDir =
|
|
13543
|
+
const cursorDir = path9.join(resolveHookHomeDir(), ".cursor");
|
|
13354
13544
|
if (!existsSync3(cursorDir)) {
|
|
13355
13545
|
return { client: "cursor", status: "absent", detail: "no ~/.cursor directory found" };
|
|
13356
13546
|
}
|
|
@@ -13423,9 +13613,9 @@ function parseCopilotPluginRecords(output) {
|
|
|
13423
13613
|
}
|
|
13424
13614
|
}
|
|
13425
13615
|
function installedCopilotPluginSourcePath(pluginName) {
|
|
13426
|
-
const configDir = process.env.COPILOT_HOME ||
|
|
13616
|
+
const configDir = process.env.COPILOT_HOME || path9.join(resolveHookHomeDir(), ".copilot");
|
|
13427
13617
|
try {
|
|
13428
|
-
const config = JSON.parse(readFileSync2(
|
|
13618
|
+
const config = JSON.parse(readFileSync2(path9.join(configDir, "config.json"), "utf8"));
|
|
13429
13619
|
const record = Array.isArray(config.installedPlugins) ? config.installedPlugins.find((candidate) => candidate?.name === pluginName) : null;
|
|
13430
13620
|
return record ? copilotPluginSourcePath(record) : null;
|
|
13431
13621
|
} catch {
|
|
@@ -13450,7 +13640,7 @@ function copilotPluginSourcePath(record) {
|
|
|
13450
13640
|
continue;
|
|
13451
13641
|
}
|
|
13452
13642
|
}
|
|
13453
|
-
if (
|
|
13643
|
+
if (path9.isAbsolute(candidate)) return candidate;
|
|
13454
13644
|
}
|
|
13455
13645
|
return null;
|
|
13456
13646
|
}
|
|
@@ -13458,14 +13648,14 @@ function sameResolvedPath(left, right) {
|
|
|
13458
13648
|
try {
|
|
13459
13649
|
return realpathSync2(left) === realpathSync2(right);
|
|
13460
13650
|
} catch {
|
|
13461
|
-
return
|
|
13651
|
+
return path9.resolve(left) === path9.resolve(right);
|
|
13462
13652
|
}
|
|
13463
13653
|
}
|
|
13464
13654
|
function resolveHookHomeDir(env = process.env, fallback = os4.homedir()) {
|
|
13465
13655
|
return env.HOME || fallback;
|
|
13466
13656
|
}
|
|
13467
13657
|
function resolveCopilotHookDir(env = process.env, homeDir = resolveHookHomeDir(env)) {
|
|
13468
|
-
return
|
|
13658
|
+
return path9.join(env.COPILOT_HOME || path9.join(homeDir, ".copilot"), "hooks");
|
|
13469
13659
|
}
|
|
13470
13660
|
function createCopilotCliAmbientContextScript(command = "lavish-axi") {
|
|
13471
13661
|
return [
|
|
@@ -13514,9 +13704,9 @@ function installCopilotCliSessionStartHook({
|
|
|
13514
13704
|
timeoutSec = 10,
|
|
13515
13705
|
onError = void 0
|
|
13516
13706
|
} = {}) {
|
|
13517
|
-
const target =
|
|
13707
|
+
const target = path9.join(hookDir, "lavish-axi.json");
|
|
13518
13708
|
try {
|
|
13519
|
-
mkdirSync2(
|
|
13709
|
+
mkdirSync2(path9.dirname(target), { recursive: true });
|
|
13520
13710
|
const current = existsSync3(target) ? JSON.parse(readFileSync2(target, "utf8")) : {};
|
|
13521
13711
|
const [updated, changed] = computeCopilotCliHookUpdate(
|
|
13522
13712
|
current,
|
|
@@ -13579,9 +13769,6 @@ async function assertHtmlFile(file) {
|
|
|
13579
13769
|
]);
|
|
13580
13770
|
}
|
|
13581
13771
|
}
|
|
13582
|
-
function isHtmlPath(file) {
|
|
13583
|
-
return file.toLowerCase().endsWith(".html") || file.toLowerCase().endsWith(".htm");
|
|
13584
|
-
}
|
|
13585
13772
|
var HEALTH_PROBE_TIMEOUT_MS = 500;
|
|
13586
13773
|
var DEFAULT_HEALTH_TIMEOUT_MS = 2e3;
|
|
13587
13774
|
var MAX_HEALTH_BODY_BYTES = 256 * 1024;
|
|
@@ -13794,7 +13981,7 @@ function serverReplacementReason(currentVersion, healthBody, forceRestart = fals
|
|
|
13794
13981
|
}
|
|
13795
13982
|
function shouldForceRestartForLocalBuild(executablePath, sourceServerExists = localSourceServerExists()) {
|
|
13796
13983
|
const localBuildEntry = fileURLToPath4(new URL("../dist/cli.mjs", import.meta.url));
|
|
13797
|
-
return sourceServerExists &&
|
|
13984
|
+
return sourceServerExists && path9.resolve(executablePath) === path9.resolve(localBuildEntry);
|
|
13798
13985
|
}
|
|
13799
13986
|
function localSourceServerExists() {
|
|
13800
13987
|
return existsSync3(fileURLToPath4(new URL("../src/server.js", import.meta.url)));
|
|
@@ -14269,7 +14456,7 @@ ${POLL_SEND_AND_END_RULE}
|
|
|
14269
14456
|
`,
|
|
14270
14457
|
reply: `Usage: lavish-axi reply <html-file> (--agent-reply "..." | --agent-reply-file <path>)
|
|
14271
14458
|
|
|
14272
|
-
Post an agent reply to the open Lavish Editor session and exit once the server confirms it was accepted. Exit 0 only when the server answers that the reply was sent, which is when the board stops showing Working. If that answer does not arrive within ${AGENT_REPLY_RECEIPT_TIMEOUT_MS / 1e3} seconds, exit non-zero with a timeout error. Use this when you are handing a result back and are not about to wait for more feedback.
|
|
14459
|
+
Post an agent reply to the open Lavish Editor session and exit once the server confirms it was accepted. Exit 0 only when the server answers that the reply was sent, which is when the board stops showing Working. If that answer does not arrive within ${AGENT_REPLY_RECEIPT_TIMEOUT_MS / 1e3} seconds, exit non-zero with a timeout error. Use this when you are handing a result back and are not about to wait for more feedback. A reply also marks every note the poll delivered to you as Done in the reviewer's panel, so reply once you have handled them.
|
|
14273
14460
|
|
|
14274
14461
|
Use \`lavish-axi poll <html-file> --agent-reply "..."\` instead when the reply should be followed by a long-poll. That command posts the reply and then keeps waiting, so it does not exit when the reply is accepted.
|
|
14275
14462
|
|
package/package.json
CHANGED
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.84",
|
|
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",
|