lavish-axi 0.1.74 → 0.1.75
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/chrome-client.js +6 -3
- package/dist/cli.mjs +267 -55
- package/package.json +1 -1
- package/plugin.json +1 -1
package/README.md
CHANGED
|
@@ -205,7 +205,7 @@ pnpm link
|
|
|
205
205
|
- **Keyboard shortcuts** - In the chrome composer, Enter sends queued prompts and Shift+Enter inserts a newline.
|
|
206
206
|
In the annotation card, Enter queues the annotation, Shift+Enter inserts a newline, and Ctrl+Enter (Cmd+Enter on macOS) queues it and sends all queued prompts immediately. Escape closes the card, same as Cancel, but only while it is empty (no text, no attachment); with unsent text or an attachment present, Escape does nothing rather than risk discarding it.
|
|
207
207
|
Cmd+I or Ctrl+I toggles between annotate and explore mode from either the browser chrome or the artifact iframe, including while focus is in a textarea or control.
|
|
208
|
-
- **Agent presence** - The browser shows when no agent is listening, keeps queued feedback for the next successful `lavish-axi poll` send even across reloads, and keeps human feedback actions available while the agent is working because the server queues them for the next poll. An agent reply concludes delivered work and returns presence to waiting.
|
|
208
|
+
- **Agent presence** - The browser shows when no agent is listening, keeps queued feedback for the next successful `lavish-axi poll` send even across reloads, and keeps human feedback actions available while the agent is working because the server queues them for the next poll. An agent reply concludes delivered work and returns presence to waiting. A supervisor-owned process listener is shown as an external listener rather than an idle captain turn.
|
|
209
209
|
In a Herdr-managed pane, set `LAVISH_AXI_HERDR_CHIME=1` to show a Herdr request notification and play its chime once an indefinite poll enters the waiting state. Immediate queued feedback and timed debug polls do not trigger a notification. The integration is off unless both `LAVISH_AXI_HERDR_CHIME` and Herdr's `HERDR_ENV` session marker equal `1`; leaving either unset or using any other value opts out. Notification delivery runs without blocking feedback: if Herdr is unavailable, rejects the notification, stalls, or fails, the poll continues unchanged.
|
|
210
210
|
Closing the last review tab while a poll is active starts a 10-second reconnect grace period. A reload or quick reconnect keeps an active poll waiting; if every review stays disconnected, the poll returns `browser_disconnected` without ending the resumable session, and the agent asks whether to reopen or end it instead of acting uninvited.
|
|
211
211
|
The no-timeout poll always writes an immediate stderr banner so it is visibly not hung; it adds the periodic stderr wait ticks only in an interactive terminal, so when stderr is piped (as under agent harnesses) the captured output carries no tick noise. Stdout always stays reserved for the final response; if the poll is interrupted or times out before feedback arrives, re-run it because feedback remains queued until delivery. Poll delivery consumes the response, so read the complete response before truncating or filtering it.
|
|
@@ -286,6 +286,8 @@ For flows, architecture, state, or sequence diagrams, open the diagram playbook
|
|
|
286
286
|
| `lavish-axi share` | `--update-key <key>` | The secret returned when the page was published; required to republish or unpublish it. |
|
|
287
287
|
| `lavish-axi share` | `--unpublish` | Replace a published page with a locked placeholder (ht-ml.app cannot delete); takes `--site` and `--update-key` and no file. |
|
|
288
288
|
| `lavish-axi share` | `--token <t>` | Attach an optional bearer token (`LAVISH_AXI_HTML_APP_TOKEN`) when creating a page; never required, and rejected on a republish or `--unpublish`, where the `update_key` is the credential. |
|
|
289
|
+
| `lavish-axi poll` | `--owner <label>` | Identify the process listening for this session; the non-empty label appears in the home session listing. `none` is reserved for sessions without a listener. A second concurrent poll is refused instead of silently returning `waiting`. |
|
|
290
|
+
| `lavish-axi poll` | `--takeover` | Explicitly displace the current poll listener. The displaced poll exits with typed `LISTENER_REPLACED`; without this flag, the new poll fails with typed `LISTENER_ACTIVE`. |
|
|
289
291
|
| `lavish-axi poll` | `--agent-reply "..."` | Show a concise agent reply in the existing browser chat, conclude delivered work, and return presence to waiting before polling again. Keep replies concise by default. |
|
|
290
292
|
| `lavish-axi poll` | `--agent-reply-file <path>` | When a longer reply is genuinely necessary, read Markdown from a file (`-` for stdin) so newlines survive quoting. Use blank-line paragraphs, `- ` / `1. ` lists, and `## ` headings so the Conversation panel renders it scannably. The Markdown subset is under Feedback controls. Cannot be combined with `--agent-reply`. |
|
|
291
293
|
| `lavish-axi poll` | `--timeout-ms <ms>` | Test/debug escape hatch only; agents should normally omit it and leave the long poll running. |
|
package/dist/chrome-client.js
CHANGED
|
@@ -988,12 +988,14 @@ function syncChat(chat, revision) {
|
|
|
988
988
|
}
|
|
989
989
|
|
|
990
990
|
function setAgentPresence(state) {
|
|
991
|
-
agentPresence = state === "listening" || state === "working" ? state : "waiting";
|
|
991
|
+
agentPresence = state === "listening" || state === "external" || state === "working" ? state : "waiting";
|
|
992
992
|
updateSendState();
|
|
993
993
|
renderSheetSummary();
|
|
994
994
|
if (presenceBanner) presenceBanner.hidden = ended || agentPresence !== "waiting";
|
|
995
995
|
|
|
996
|
-
|
|
996
|
+
// A supervisor-owned process-only listener is busy on the agent's behalf. It must not make the
|
|
997
|
+
// composer look like an idle captain turn while the owning agent is offline.
|
|
998
|
+
if (agentPresence !== "working" && agentPresence !== "external") {
|
|
997
999
|
if (workingBubble) workingBubble.remove();
|
|
998
1000
|
workingBubble = null;
|
|
999
1001
|
return;
|
|
@@ -1209,6 +1211,7 @@ function sheetSummary() {
|
|
|
1209
1211
|
}
|
|
1210
1212
|
if (unreadAgentReply) return { text: unreadAgentReply, accent: false, unread: true };
|
|
1211
1213
|
if (agentPresence === "working") return { text: "Agent is working…", accent: false, unread: false };
|
|
1214
|
+
if (agentPresence === "external") return { text: "External listener active", accent: false, unread: false };
|
|
1212
1215
|
if (agentPresence === "listening") return { text: "Agent listening", accent: false, unread: false };
|
|
1213
1216
|
return { text: "Agent not listening", accent: false, unread: false };
|
|
1214
1217
|
}
|
|
@@ -4075,7 +4078,7 @@ events.set("chat-sync", (data) => {
|
|
|
4075
4078
|
rememberChatAckIds(data.ack_ids);
|
|
4076
4079
|
syncChat(data.chat || [], data.chat_revision);
|
|
4077
4080
|
});
|
|
4078
|
-
events.set("agent-presence", (data) => setAgentPresence(data.state));
|
|
4081
|
+
events.set("agent-presence", (data) => setAgentPresence(data.mode === "external-listener" ? "external" : data.state));
|
|
4079
4082
|
events.set("layout-warnings", (data) => setLayoutWarnings(data.warnings || []));
|
|
4080
4083
|
events.set("ended", () => markSessionEnded());
|
|
4081
4084
|
connectLiveEvents();
|
package/dist/cli.mjs
CHANGED
|
@@ -9392,6 +9392,7 @@ var BROWSER_DISCONNECT_GRACE_MS = 1e4;
|
|
|
9392
9392
|
var ARTIFACT_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads";
|
|
9393
9393
|
var ATTACHMENT_SWEEP_INTERVAL_MS = 60 * 6e4;
|
|
9394
9394
|
var SHUTDOWN_REASONS = /* @__PURE__ */ new Set(["upgrade", "local-build", "stop"]);
|
|
9395
|
+
var AGENT_LISTENER_LABEL = "agent-listener";
|
|
9395
9396
|
var RELOAD_DEBOUNCE_MS = 100;
|
|
9396
9397
|
var BATCH_RELOAD_DEBOUNCE_MS = 900;
|
|
9397
9398
|
function defaultWhiteboardAssetsDir() {
|
|
@@ -9520,8 +9521,10 @@ async function serve({
|
|
|
9520
9521
|
const watchers = /* @__PURE__ */ new Map();
|
|
9521
9522
|
const activePolls = /* @__PURE__ */ new Map();
|
|
9522
9523
|
const deliveredFeedback = /* @__PURE__ */ new Set();
|
|
9524
|
+
const pollOwnershipLock = new AsyncMutex();
|
|
9523
9525
|
const liveEventClients = /* @__PURE__ */ new Map();
|
|
9524
9526
|
const browserDisconnectTimers = /* @__PURE__ */ new Map();
|
|
9527
|
+
const browserDisconnectPending = /* @__PURE__ */ new Set();
|
|
9525
9528
|
const whiteboardChannelSecret = crypto6.randomBytes(32);
|
|
9526
9529
|
const outstandingRepairBatches = /* @__PURE__ */ new Set();
|
|
9527
9530
|
const diagnosticViewportClasses = resolveDiagnosticViewportClasses();
|
|
@@ -9544,7 +9547,10 @@ async function serve({
|
|
|
9544
9547
|
events.on("reload", (key) => broadcastLiveEvent("reload", key));
|
|
9545
9548
|
events.on("agent-reply", (key, entry) => broadcastLiveEvent("agent-reply", key, entry));
|
|
9546
9549
|
events.on("chat-sync", (key, session) => broadcastLiveEvent("chat-sync", key, serializeChatSync(session)));
|
|
9547
|
-
events.on(
|
|
9550
|
+
events.on(
|
|
9551
|
+
"agent-presence",
|
|
9552
|
+
(key, state) => broadcastLiveEvent("agent-presence", key, presenceEventData(key, state, activePolls, deliveredFeedback))
|
|
9553
|
+
);
|
|
9548
9554
|
events.on("layout-warnings", (key, warnings) => broadcastLiveEvent("layout-warnings", key, { warnings }));
|
|
9549
9555
|
events.on("ended", (key, endedBy) => broadcastLiveEvent("ended", key, { ended_by: endedBy || null }));
|
|
9550
9556
|
function hasLiveEventClient(key) {
|
|
@@ -9564,13 +9570,17 @@ async function serve({
|
|
|
9564
9570
|
if (shuttingDown || hasLiveEventClient(key) || !activePolls.has(key)) return;
|
|
9565
9571
|
const timer = setTimeout(() => {
|
|
9566
9572
|
browserDisconnectTimers.delete(key);
|
|
9567
|
-
if (!hasLiveEventClient(key) && activePolls.has(key))
|
|
9573
|
+
if (!hasLiveEventClient(key) && activePolls.has(key)) {
|
|
9574
|
+
browserDisconnectPending.add(key);
|
|
9575
|
+
events.emit("browser-disconnected", key);
|
|
9576
|
+
}
|
|
9568
9577
|
}, browserDisconnectGraceMs);
|
|
9569
9578
|
timer.unref?.();
|
|
9570
9579
|
browserDisconnectTimers.set(key, timer);
|
|
9571
9580
|
}
|
|
9572
9581
|
function attachLiveEventClient(client, key, onClose) {
|
|
9573
9582
|
clearBrowserDisconnectTimer(key);
|
|
9583
|
+
browserDisconnectPending.delete(key);
|
|
9574
9584
|
liveEventClients.set(client, key);
|
|
9575
9585
|
refreshIdleTimer();
|
|
9576
9586
|
let cleanedUp = false;
|
|
@@ -9591,7 +9601,8 @@ async function serve({
|
|
|
9591
9601
|
return;
|
|
9592
9602
|
}
|
|
9593
9603
|
client.sendEvent("chat-sync", serializeChatSync(session));
|
|
9594
|
-
|
|
9604
|
+
const presence = computePresence(key, activePolls, deliveredFeedback);
|
|
9605
|
+
client.sendEvent("agent-presence", presenceEventData(key, presence, activePolls, deliveredFeedback));
|
|
9595
9606
|
if (session?.status === "ended") client.sendEvent("ended", { ended_by: session.ended_by || null });
|
|
9596
9607
|
}
|
|
9597
9608
|
function requestedBindIsRecoverable() {
|
|
@@ -9627,7 +9638,6 @@ async function serve({
|
|
|
9627
9638
|
function finishFeedbackDelivery(key, result) {
|
|
9628
9639
|
if (result.status !== "feedback") return;
|
|
9629
9640
|
markFeedbackDelivered(key, activePolls, deliveredFeedback, events);
|
|
9630
|
-
if (result.session_ended) clearFeedbackDelivery(key, activePolls, deliveredFeedback, events);
|
|
9631
9641
|
}
|
|
9632
9642
|
async function restoreClosedFeedback(key, result) {
|
|
9633
9643
|
if (result.status !== "feedback") return;
|
|
@@ -9754,7 +9764,11 @@ async function serve({
|
|
|
9754
9764
|
app: "lavish-axi",
|
|
9755
9765
|
version,
|
|
9756
9766
|
...networkStale ? { network_stale: true } : {},
|
|
9757
|
-
...networkWarning ? { network_warning: networkWarning } : {}
|
|
9767
|
+
...networkWarning ? { network_warning: networkWarning } : {},
|
|
9768
|
+
listeners: [...activePolls].map(([key, holder]) => ({
|
|
9769
|
+
key,
|
|
9770
|
+
label: listenerLabel(holder)
|
|
9771
|
+
}))
|
|
9758
9772
|
});
|
|
9759
9773
|
});
|
|
9760
9774
|
let shutdownResolve;
|
|
@@ -9804,13 +9818,44 @@ async function serve({
|
|
|
9804
9818
|
next(error);
|
|
9805
9819
|
}
|
|
9806
9820
|
});
|
|
9807
|
-
|
|
9808
|
-
|
|
9821
|
+
async function publishAgentReply(key, text) {
|
|
9822
|
+
const session = await store.addAgentReply(key, text);
|
|
9823
|
+
if (!session) return null;
|
|
9824
|
+
const lastEntry = session.chat?.at(-1);
|
|
9825
|
+
const entry = serializeChat([
|
|
9826
|
+
lastEntry?.role === "agent" ? lastEntry : { role: "agent", text, at: session.updated_at }
|
|
9827
|
+
])[0];
|
|
9828
|
+
events.emit("agent-reply", key, entry);
|
|
9829
|
+
events.emit("chat-sync", key, session);
|
|
9830
|
+
clearFeedbackDelivery(key, activePolls, deliveredFeedback, events);
|
|
9831
|
+
return session;
|
|
9832
|
+
}
|
|
9833
|
+
const handlePoll = async (req, res, next) => {
|
|
9834
|
+
const takeover = req.query.takeover === "1";
|
|
9835
|
+
const agentReply = req.method === "POST" && req.body?.agent_reply !== void 0 ? String(req.body.agent_reply) : null;
|
|
9836
|
+
if (takeover && req.method !== "POST") {
|
|
9837
|
+
res.status(405).json({ error: "poll takeover requires POST" });
|
|
9838
|
+
return;
|
|
9839
|
+
}
|
|
9840
|
+
if (req.method === "POST" && (agentReply === null ? !takeover : !agentReply.trim())) {
|
|
9841
|
+
res.status(405).json({ error: "POST /api/poll requires agent_reply or takeover=1" });
|
|
9842
|
+
return;
|
|
9843
|
+
}
|
|
9844
|
+
let requestClosed = Boolean(req.aborted || req.method !== "POST" && req.destroyed);
|
|
9809
9845
|
let cleanupPoll = null;
|
|
9846
|
+
let claimedHolder = null;
|
|
9810
9847
|
const onRequestClose = () => {
|
|
9848
|
+
if (req.method === "POST" && !req.aborted) return;
|
|
9811
9849
|
requestClosed = true;
|
|
9812
9850
|
cleanupPoll?.();
|
|
9813
9851
|
};
|
|
9852
|
+
const onResponseClose = () => {
|
|
9853
|
+
if (req.method === "POST" && !res.writableEnded) {
|
|
9854
|
+
requestClosed = true;
|
|
9855
|
+
cleanupPoll?.();
|
|
9856
|
+
}
|
|
9857
|
+
};
|
|
9858
|
+
res.on("close", onResponseClose);
|
|
9814
9859
|
const detachRequestClose = () => req.off("close", onRequestClose);
|
|
9815
9860
|
req.on("close", onRequestClose);
|
|
9816
9861
|
try {
|
|
@@ -9824,20 +9869,86 @@ async function serve({
|
|
|
9824
9869
|
};
|
|
9825
9870
|
const file = await canonicalFile(String(req.query.file || ""));
|
|
9826
9871
|
const key = sessionKey(file);
|
|
9872
|
+
const ownerValue = typeof req.query.owner === "string" ? req.query.owner.trim() : "";
|
|
9873
|
+
if (ownerValue.toLowerCase() === "none" || ownerValue.startsWith("-")) {
|
|
9874
|
+
detachRequestClose();
|
|
9875
|
+
res.status(400).json({
|
|
9876
|
+
status: "error",
|
|
9877
|
+
code: "VALIDATION_ERROR",
|
|
9878
|
+
error: "--owner none is reserved; pass a different listener label"
|
|
9879
|
+
});
|
|
9880
|
+
return;
|
|
9881
|
+
}
|
|
9882
|
+
const owner = ownerValue || null;
|
|
9883
|
+
if (hasPresentOriginOrReferer(req) && !isSameOriginRequest(req, allowedHostnames, allowAnyHostname)) {
|
|
9884
|
+
detachRequestClose();
|
|
9885
|
+
res.status(403).json({ error: "cross-origin poll takeover rejected" });
|
|
9886
|
+
return;
|
|
9887
|
+
}
|
|
9888
|
+
const { holder, previousPresence, conflict, missing } = await pollOwnershipLock.runExclusive(async () => {
|
|
9889
|
+
const currentHolder = activePolls.get(key);
|
|
9890
|
+
if (currentHolder && !takeover) return { conflict: listenerConflict(currentHolder, "LISTENER_ACTIVE") };
|
|
9891
|
+
const nextHolder = { key, owner, startedAt: Date.now(), replaced: false, replace: null };
|
|
9892
|
+
claimedHolder = nextHolder;
|
|
9893
|
+
const priorPresence = presenceSignature(key, activePolls, deliveredFeedback);
|
|
9894
|
+
if (currentHolder) {
|
|
9895
|
+
currentHolder.replaced = true;
|
|
9896
|
+
activePolls.set(key, nextHolder);
|
|
9897
|
+
currentHolder.replace?.();
|
|
9898
|
+
} else {
|
|
9899
|
+
activePolls.set(key, nextHolder);
|
|
9900
|
+
}
|
|
9901
|
+
deliveredFeedback.delete(key);
|
|
9902
|
+
if (agentReply !== null) {
|
|
9903
|
+
const session = await publishAgentReply(key, agentReply);
|
|
9904
|
+
if (!session) {
|
|
9905
|
+
activePolls.delete(key);
|
|
9906
|
+
return { missing: true };
|
|
9907
|
+
}
|
|
9908
|
+
}
|
|
9909
|
+
return { holder: nextHolder, previousPresence: priorPresence };
|
|
9910
|
+
});
|
|
9911
|
+
if (conflict) {
|
|
9912
|
+
detachRequestClose();
|
|
9913
|
+
res.status(409).json(conflict);
|
|
9914
|
+
return;
|
|
9915
|
+
}
|
|
9916
|
+
if (missing) {
|
|
9917
|
+
detachRequestClose();
|
|
9918
|
+
res.status(404).json({ error: "session not found" });
|
|
9919
|
+
return;
|
|
9920
|
+
}
|
|
9827
9921
|
const timeoutMs = req.query.timeoutMs === void 0 ? null : Math.max(0, Math.min(Number(req.query.timeoutMs || 0), 2147483647));
|
|
9828
9922
|
const immediate = await store.takeFeedback(key);
|
|
9829
9923
|
if (immediate.status !== "waiting") {
|
|
9830
|
-
if (
|
|
9924
|
+
if (holder.replaced) {
|
|
9831
9925
|
await restoreClosedFeedback(key, immediate);
|
|
9832
9926
|
detachRequestClose();
|
|
9927
|
+
res.json(listenerConflict(holder, "LISTENER_REPLACED"));
|
|
9928
|
+
releasePollListener(holder, activePolls, deliveredFeedback, events);
|
|
9929
|
+
return;
|
|
9930
|
+
}
|
|
9931
|
+
if (requestClosed || res.writableEnded) {
|
|
9932
|
+
await restoreClosedFeedback(key, immediate);
|
|
9933
|
+
releasePollListener(holder, activePolls, deliveredFeedback, events);
|
|
9934
|
+
detachRequestClose();
|
|
9833
9935
|
return;
|
|
9834
9936
|
}
|
|
9835
9937
|
finishFeedbackDelivery(key, immediate);
|
|
9938
|
+
releasePollListener(holder, activePolls, deliveredFeedback, events);
|
|
9939
|
+
if (immediate.session_ended) clearFeedbackDelivery(key, activePolls, deliveredFeedback, events);
|
|
9836
9940
|
detachRequestClose();
|
|
9837
9941
|
res.json(immediate);
|
|
9838
9942
|
return;
|
|
9839
9943
|
}
|
|
9840
|
-
if (
|
|
9944
|
+
if (holder.replaced) {
|
|
9945
|
+
detachRequestClose();
|
|
9946
|
+
res.json(listenerConflict(holder, "LISTENER_REPLACED"));
|
|
9947
|
+
releasePollListener(holder, activePolls, deliveredFeedback, events);
|
|
9948
|
+
return;
|
|
9949
|
+
}
|
|
9950
|
+
if (requestClosed || res.writableEnded) {
|
|
9951
|
+
releasePollListener(holder, activePolls, deliveredFeedback, events);
|
|
9841
9952
|
detachRequestClose();
|
|
9842
9953
|
return;
|
|
9843
9954
|
}
|
|
@@ -9851,7 +9962,6 @@ async function serve({
|
|
|
9851
9962
|
}, pollHeartbeatMs);
|
|
9852
9963
|
heartbeat.unref?.();
|
|
9853
9964
|
}
|
|
9854
|
-
setPollActive(key, activePolls, deliveredFeedback, events, true);
|
|
9855
9965
|
refreshIdleTimer();
|
|
9856
9966
|
let timer = null;
|
|
9857
9967
|
let cleaned = false;
|
|
@@ -9864,19 +9974,45 @@ async function serve({
|
|
|
9864
9974
|
events.off("feedback", onFeedback);
|
|
9865
9975
|
events.off("ended", onFeedback);
|
|
9866
9976
|
events.off("browser-disconnected", onBrowserDisconnected);
|
|
9867
|
-
|
|
9868
|
-
if (!activePolls.has(key))
|
|
9977
|
+
releasePollListener(holder, activePolls, deliveredFeedback, events);
|
|
9978
|
+
if (!activePolls.has(key)) {
|
|
9979
|
+
clearBrowserDisconnectTimer(key);
|
|
9980
|
+
browserDisconnectPending.delete(key);
|
|
9981
|
+
}
|
|
9869
9982
|
refreshIdleTimer();
|
|
9870
9983
|
cleanupPoll = null;
|
|
9871
9984
|
detachRequestClose();
|
|
9985
|
+
res.off("close", onResponseClose);
|
|
9872
9986
|
};
|
|
9987
|
+
const respondReplacement = () => {
|
|
9988
|
+
if (responding || res.writableEnded) return;
|
|
9989
|
+
responding = true;
|
|
9990
|
+
try {
|
|
9991
|
+
const replacement = listenerConflict(holder, "LISTENER_REPLACED");
|
|
9992
|
+
if (streamHeartbeat) res.end(JSON.stringify(replacement));
|
|
9993
|
+
else res.json(replacement);
|
|
9994
|
+
} finally {
|
|
9995
|
+
cleanup();
|
|
9996
|
+
}
|
|
9997
|
+
};
|
|
9998
|
+
holder.replace = respondReplacement;
|
|
9873
9999
|
const respond = async (forcedResult = null) => {
|
|
9874
10000
|
if (responding || res.writableEnded) return;
|
|
9875
10001
|
responding = true;
|
|
10002
|
+
let finalSessionEnded = false;
|
|
9876
10003
|
try {
|
|
9877
10004
|
const result = await store.takeFeedback(key);
|
|
9878
10005
|
const responseResult = forcedResult && result.status === "waiting" ? forcedResult : result;
|
|
9879
|
-
|
|
10006
|
+
finalSessionEnded = responseResult?.session_ended === true;
|
|
10007
|
+
if (holder.replaced) {
|
|
10008
|
+
if (responseResult.status !== "waiting") await restoreClosedFeedback(key, responseResult);
|
|
10009
|
+
if (!requestClosed && !res.writableEnded) {
|
|
10010
|
+
if (streamHeartbeat) res.end(JSON.stringify(listenerConflict(holder, "LISTENER_REPLACED")));
|
|
10011
|
+
else res.json(listenerConflict(holder, "LISTENER_REPLACED"));
|
|
10012
|
+
}
|
|
10013
|
+
return;
|
|
10014
|
+
}
|
|
10015
|
+
if (requestClosed || res.writableEnded) {
|
|
9880
10016
|
await restoreClosedFeedback(key, responseResult);
|
|
9881
10017
|
return;
|
|
9882
10018
|
}
|
|
@@ -9888,6 +10024,7 @@ async function serve({
|
|
|
9888
10024
|
}
|
|
9889
10025
|
} finally {
|
|
9890
10026
|
cleanup();
|
|
10027
|
+
if (finalSessionEnded) clearFeedbackDelivery(key, activePolls, deliveredFeedback, events);
|
|
9891
10028
|
}
|
|
9892
10029
|
};
|
|
9893
10030
|
const onFeedback = (changedKey) => {
|
|
@@ -9904,17 +10041,33 @@ async function serve({
|
|
|
9904
10041
|
events.on("ended", onFeedback);
|
|
9905
10042
|
events.on("browser-disconnected", onBrowserDisconnected);
|
|
9906
10043
|
cleanupPoll = cleanup;
|
|
9907
|
-
if (
|
|
10044
|
+
if (holder.replaced) {
|
|
10045
|
+
respondReplacement();
|
|
10046
|
+
return;
|
|
10047
|
+
}
|
|
10048
|
+
if (requestClosed || res.writableEnded) {
|
|
9908
10049
|
cleanup();
|
|
9909
10050
|
return;
|
|
9910
10051
|
}
|
|
10052
|
+
if (browserDisconnectPending.has(key)) {
|
|
10053
|
+
browserDisconnectPending.delete(key);
|
|
10054
|
+
onBrowserDisconnected(key);
|
|
10055
|
+
return;
|
|
10056
|
+
}
|
|
10057
|
+
const nextPresence = presenceSignature(key, activePolls, deliveredFeedback);
|
|
10058
|
+
if (nextPresence !== previousPresence) {
|
|
10059
|
+
events.emit("agent-presence", key, computePresence(key, activePolls, deliveredFeedback));
|
|
10060
|
+
}
|
|
9911
10061
|
timer = timeoutMs === null ? null : setTimeout(() => respond().catch(handleRespondError), timeoutMs);
|
|
9912
10062
|
} catch (error) {
|
|
9913
10063
|
cleanupPoll?.();
|
|
10064
|
+
if (claimedHolder) releasePollListener(claimedHolder, activePolls, deliveredFeedback, events);
|
|
9914
10065
|
detachRequestClose();
|
|
9915
10066
|
next(error);
|
|
9916
10067
|
}
|
|
9917
|
-
}
|
|
10068
|
+
};
|
|
10069
|
+
app.get("/api/poll", handlePoll);
|
|
10070
|
+
app.post("/api/poll", handlePoll);
|
|
9918
10071
|
app.post("/api/:key/prompts", async (req, res, next) => {
|
|
9919
10072
|
try {
|
|
9920
10073
|
if (!isSameOriginRequest(req, allowedHostnames, allowAnyHostname)) {
|
|
@@ -10066,18 +10219,11 @@ async function serve({
|
|
|
10066
10219
|
});
|
|
10067
10220
|
app.post("/api/:key/agent-reply", async (req, res, next) => {
|
|
10068
10221
|
try {
|
|
10069
|
-
const
|
|
10070
|
-
const session = await store.addAgentReply(req.params.key, text);
|
|
10222
|
+
const session = await publishAgentReply(req.params.key, String(req.body?.text || ""));
|
|
10071
10223
|
if (!session) {
|
|
10072
10224
|
res.status(404).json({ error: "session not found" });
|
|
10073
10225
|
return;
|
|
10074
10226
|
}
|
|
10075
|
-
const entry = serializeChat([
|
|
10076
|
-
session.chat?.at(-1)?.role === "agent" ? session.chat.at(-1) : { role: "agent", text, at: session.updated_at }
|
|
10077
|
-
])[0];
|
|
10078
|
-
events.emit("agent-reply", req.params.key, entry);
|
|
10079
|
-
events.emit("chat-sync", req.params.key, session);
|
|
10080
|
-
clearFeedbackDelivery(req.params.key, activePolls, deliveredFeedback, events);
|
|
10081
10227
|
res.json({ status: "sent" });
|
|
10082
10228
|
} catch (error) {
|
|
10083
10229
|
next(error);
|
|
@@ -11107,19 +11253,23 @@ function hasLiveReloadRootOptIn(html) {
|
|
|
11107
11253
|
if (/<html\b[^>]*\sdata-lavish-live-reload-root(?:[\s=>/]|$)[^>]*>/i.test(searchableHtml)) return true;
|
|
11108
11254
|
return /<meta\b(?=[^>]*name=["']lavish-live-reload["'])(?=[^>]*content=["']root["'])[^>]*>/i.test(searchableHtml);
|
|
11109
11255
|
}
|
|
11110
|
-
function
|
|
11111
|
-
|
|
11112
|
-
const
|
|
11113
|
-
|
|
11114
|
-
|
|
11115
|
-
if (
|
|
11116
|
-
|
|
11117
|
-
|
|
11118
|
-
|
|
11119
|
-
|
|
11120
|
-
|
|
11121
|
-
|
|
11122
|
-
|
|
11256
|
+
function releasePollListener(holder, activePolls, deliveredFeedback, events) {
|
|
11257
|
+
if (activePolls.get(holder.key) !== holder) return;
|
|
11258
|
+
const previousPresence = computePresence(holder.key, activePolls, deliveredFeedback);
|
|
11259
|
+
activePolls.delete(holder.key);
|
|
11260
|
+
const nextPresence = computePresence(holder.key, activePolls, deliveredFeedback);
|
|
11261
|
+
if (nextPresence !== previousPresence) events.emit("agent-presence", holder.key, nextPresence);
|
|
11262
|
+
}
|
|
11263
|
+
function listenerLabel(holder) {
|
|
11264
|
+
return holder.owner || AGENT_LISTENER_LABEL;
|
|
11265
|
+
}
|
|
11266
|
+
function listenerConflict(holder, code) {
|
|
11267
|
+
return {
|
|
11268
|
+
status: "error",
|
|
11269
|
+
code,
|
|
11270
|
+
error: code === "LISTENER_ACTIVE" ? "Lavish Editor already has an active poll listener" : "Lavish Editor poll listener was replaced",
|
|
11271
|
+
holder: { label: listenerLabel(holder), age_ms: Math.max(0, Date.now() - holder.startedAt) }
|
|
11272
|
+
};
|
|
11123
11273
|
}
|
|
11124
11274
|
function markFeedbackDelivered(key, activePolls, deliveredFeedback, events) {
|
|
11125
11275
|
const previousPresence = computePresence(key, activePolls, deliveredFeedback);
|
|
@@ -11138,9 +11288,21 @@ function clearFeedbackDelivery(key, activePolls, deliveredFeedback, events) {
|
|
|
11138
11288
|
}
|
|
11139
11289
|
}
|
|
11140
11290
|
function computePresence(key, activePolls, deliveredFeedback) {
|
|
11141
|
-
if (activePolls.has(key)) return "listening";
|
|
11142
11291
|
if (deliveredFeedback.has(key)) return "working";
|
|
11143
|
-
return "waiting";
|
|
11292
|
+
return activePolls.has(key) ? "listening" : "waiting";
|
|
11293
|
+
}
|
|
11294
|
+
function presenceSignature(key, activePolls, deliveredFeedback) {
|
|
11295
|
+
return `${computePresence(key, activePolls, deliveredFeedback)}:${presenceMode(key, activePolls, deliveredFeedback)}`;
|
|
11296
|
+
}
|
|
11297
|
+
function presenceMode(key, activePolls, deliveredFeedback) {
|
|
11298
|
+
if (deliveredFeedback.has(key)) return "agent-busy";
|
|
11299
|
+
const holder = activePolls.get(key);
|
|
11300
|
+
if (!holder) return "waiting-on-captain";
|
|
11301
|
+
return holder.owner ? "external-listener" : "agent-listener";
|
|
11302
|
+
}
|
|
11303
|
+
function presenceEventData(key, state, activePolls, deliveredFeedback) {
|
|
11304
|
+
const mode = presenceMode(key, activePolls, deliveredFeedback);
|
|
11305
|
+
return mode === "waiting-on-captain" ? { state } : { state, mode };
|
|
11144
11306
|
}
|
|
11145
11307
|
function chromeIcon(paths, size = 16, strokeWidth = 1.7) {
|
|
11146
11308
|
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
|
|
@@ -11621,14 +11783,14 @@ var POLL_SEND_AND_END_RULE = "`Send & End` ends the session. Its final feedback
|
|
|
11621
11783
|
var POLL_AGENT_REPLY_RULE = "Keep the reply concise. Only when a longer reply is genuinely necessary, use Markdown structure - blank-line paragraphs, `- ` / `1. ` lists, `## ` headings - so it renders scannably instead of a wall of text, and pass that body with `--agent-reply-file <path>` (`-` reads stdin) so newlines survive quoting.";
|
|
11622
11784
|
var POLL_AGENT_REPLY_HELP_POINTER = "The Conversation panel's Markdown subset is in README's Feedback controls bullet.";
|
|
11623
11785
|
var POLL_AGENT_REPLY_NEXT_POINTER = "The Conversation panel's Markdown subset is in `lavish-axi poll --help` and README.";
|
|
11624
|
-
var POLL_VALUE_FLAGS = ["--agent-reply", "--agent-reply-file", "--timeout-ms"];
|
|
11786
|
+
var POLL_VALUE_FLAGS = ["--agent-reply", "--agent-reply-file", "--timeout-ms", "--owner"];
|
|
11625
11787
|
var AGENT_REPLY_JSON_LIMIT_BYTES = 2 * 1024 * 1024;
|
|
11626
|
-
var AGENT_REPLY_JSON_ENVELOPE_BYTES = Buffer.byteLength(JSON.stringify({
|
|
11788
|
+
var AGENT_REPLY_JSON_ENVELOPE_BYTES = Buffer.byteLength(JSON.stringify({ agent_reply: "" }));
|
|
11627
11789
|
var AGENT_REPLY_INPUT_LIMIT_BYTES = AGENT_REPLY_JSON_LIMIT_BYTES - AGENT_REPLY_JSON_ENVELOPE_BYTES;
|
|
11628
11790
|
var AGENT_REPLY_LIMIT_LABEL = "2 MB JSON request limit";
|
|
11629
11791
|
var POLL_STATE_HEADER = "lavish-poll-state";
|
|
11630
11792
|
var CODEX_POLL_WAKE_PATH_GUIDANCE = "Codex detected: completed background tasks may not resume Codex automatically, so keep the poll attached to the active turn.";
|
|
11631
|
-
var VERSION = "0.1.
|
|
11793
|
+
var VERSION = "0.1.75";
|
|
11632
11794
|
function detectInvokingAgent(env = process.env) {
|
|
11633
11795
|
return ["CODEX_SANDBOX", "CODEX_THREAD_ID"].some((key) => Object.hasOwn(env, key)) ? "codex" : "generic";
|
|
11634
11796
|
}
|
|
@@ -11771,7 +11933,8 @@ function createHomeOutput({ bin, sessions, includeSessions = true, agent = "gene
|
|
|
11771
11933
|
file: session.file,
|
|
11772
11934
|
status: session.status,
|
|
11773
11935
|
url: session.url,
|
|
11774
|
-
pending_prompts: session.pending_prompts || 0
|
|
11936
|
+
pending_prompts: session.pending_prompts || 0,
|
|
11937
|
+
listener: session.listener || "none"
|
|
11775
11938
|
}))
|
|
11776
11939
|
} : {},
|
|
11777
11940
|
visual_guidance: [
|
|
@@ -11891,14 +12054,24 @@ async function pollCommand(args) {
|
|
|
11891
12054
|
if (!file) {
|
|
11892
12055
|
throw new AxiError("HTML file path is required", "VALIDATION_ERROR", ["Run `lavish-axi poll <html-file>`"]);
|
|
11893
12056
|
}
|
|
12057
|
+
const ownerFlag = inspectValueFlag(args, "--owner");
|
|
12058
|
+
const owner = ownerFlag.present ? String(ownerFlag.value || "").trim() : null;
|
|
12059
|
+
if (ownerFlag.present && (!owner || owner.startsWith("-") || owner.toLowerCase() === "none")) {
|
|
12060
|
+
throw new AxiError(
|
|
12061
|
+
owner?.toLowerCase() === "none" ? "--owner none is reserved" : "--owner requires a non-empty label",
|
|
12062
|
+
"VALIDATION_ERROR",
|
|
12063
|
+
["Pass `--owner <label>` to identify the process listening for feedback"]
|
|
12064
|
+
);
|
|
12065
|
+
}
|
|
12066
|
+
const takeover = args.includes("--takeover");
|
|
11894
12067
|
const agentReply = await resolveAgentReply(args);
|
|
11895
12068
|
const absolute = await canonicalFile(file);
|
|
11896
12069
|
const baseUrl = await ensureServer();
|
|
11897
|
-
if (agentReply) {
|
|
11898
|
-
await postJson(`${baseUrl}/api/${sessionKey(absolute)}/agent-reply`, { text: agentReply });
|
|
11899
|
-
}
|
|
11900
12070
|
const timeoutMs = flagValue(args, "--timeout-ms");
|
|
11901
|
-
const
|
|
12071
|
+
const query = new URLSearchParams({ file: absolute });
|
|
12072
|
+
if (timeoutMs) query.set("timeoutMs", timeoutMs);
|
|
12073
|
+
if (owner) query.set("owner", owner);
|
|
12074
|
+
if (takeover) query.set("takeover", "1");
|
|
11902
12075
|
const onPollSignal = (signal) => {
|
|
11903
12076
|
process.stderr.write(`
|
|
11904
12077
|
${pollInterruptedText(absolute)}
|
|
@@ -11914,13 +12087,25 @@ ${pollInterruptedText(absolute)}
|
|
|
11914
12087
|
narrateTicks: shouldNarratePollWaitTicks({ isTTY: process.stderr.isTTY })
|
|
11915
12088
|
});
|
|
11916
12089
|
try {
|
|
11917
|
-
const
|
|
11918
|
-
|
|
11919
|
-
|
|
12090
|
+
const request = agentReply || takeover ? {
|
|
12091
|
+
method: "POST",
|
|
12092
|
+
headers: { "content-type": "application/json" },
|
|
12093
|
+
body: JSON.stringify(agentReply ? { agent_reply: agentReply } : {})
|
|
12094
|
+
} : {};
|
|
12095
|
+
const response = await fetchJson(`${baseUrl}/api/poll?${query}`, {
|
|
12096
|
+
...request,
|
|
12097
|
+
// Poll ownership is claimed before the response is available. Retrying a transport failure
|
|
12098
|
+
// can leave the first claim alive and make the retry reject itself as LISTENER_ACTIVE.
|
|
12099
|
+
retries: 0,
|
|
11920
12100
|
onResponse: (pollResponse) => {
|
|
11921
12101
|
if (pollResponse.headers.get(POLL_STATE_HEADER) === "listening") notifyHerdrPollReady();
|
|
11922
12102
|
}
|
|
11923
12103
|
});
|
|
12104
|
+
if (response.code === "LISTENER_REPLACED") {
|
|
12105
|
+
throw new AxiError("Lavish Editor poll listener was replaced by a takeover", "LISTENER_REPLACED", [
|
|
12106
|
+
`Re-run lavish-axi poll ${absolute} only if you intend to take over listening`
|
|
12107
|
+
]);
|
|
12108
|
+
}
|
|
11924
12109
|
return createPollOutput({ file: absolute, response, agent: detectInvokingAgent(process.env) });
|
|
11925
12110
|
} finally {
|
|
11926
12111
|
waitReporter?.stop();
|
|
@@ -12779,7 +12964,12 @@ async function serverCommand(args) {
|
|
|
12779
12964
|
}
|
|
12780
12965
|
async function visibleSessions() {
|
|
12781
12966
|
const store = new SessionStore(stateFile());
|
|
12782
|
-
|
|
12967
|
+
const sessions = (await store.listSessions()).filter((session) => session.status !== "ended");
|
|
12968
|
+
const { health } = await findRunningServer(defaultPort());
|
|
12969
|
+
const listeners = new Map(
|
|
12970
|
+
Array.isArray(health?.listeners) ? health.listeners.filter((listener) => listener && typeof listener.key === "string").map((listener) => [listener.key, listener.label || "agent-listener"]) : []
|
|
12971
|
+
);
|
|
12972
|
+
return sessions.map((session) => ({ ...session, listener: listeners.get(session.key) || "none" }));
|
|
12783
12973
|
}
|
|
12784
12974
|
async function assertHtmlFile(file) {
|
|
12785
12975
|
if (!isHtmlPath(file)) {
|
|
@@ -13002,11 +13192,15 @@ function createServerSpawnOptions(logFd = null) {
|
|
|
13002
13192
|
env: { ...process.env, LAVISH_AXI_NO_OPEN: "1" }
|
|
13003
13193
|
};
|
|
13004
13194
|
}
|
|
13005
|
-
async function fetchJson(url, { retries = 0, retryDelayMs = 250, onResponse = null } = {}) {
|
|
13195
|
+
async function fetchJson(url, { retries = 0, retryDelayMs = 250, onResponse = null, method = "GET", headers, body } = {}) {
|
|
13006
13196
|
let response;
|
|
13007
13197
|
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
13008
13198
|
try {
|
|
13009
|
-
response = await fetch(url
|
|
13199
|
+
response = await fetch(url, {
|
|
13200
|
+
method,
|
|
13201
|
+
...headers ? { headers } : {},
|
|
13202
|
+
...body === void 0 ? {} : { body }
|
|
13203
|
+
});
|
|
13010
13204
|
break;
|
|
13011
13205
|
} catch (error) {
|
|
13012
13206
|
if (error instanceof AxiError) throw error;
|
|
@@ -13016,6 +13210,22 @@ async function fetchJson(url, { retries = 0, retryDelayMs = 250, onResponse = nu
|
|
|
13016
13210
|
}
|
|
13017
13211
|
if (!response) throw serverConnectionError();
|
|
13018
13212
|
if (!response.ok) {
|
|
13213
|
+
let payload = null;
|
|
13214
|
+
try {
|
|
13215
|
+
payload = await response.json();
|
|
13216
|
+
} catch {
|
|
13217
|
+
}
|
|
13218
|
+
if (payload?.code) {
|
|
13219
|
+
const holder = payload.code.startsWith("LISTENER_") && payload.holder;
|
|
13220
|
+
const holderDetail = holder && typeof holder.label === "string" && Number.isFinite(holder.age_ms) ? ` (current listener: ${holder.label}; active for ${Math.max(0, holder.age_ms)}ms)` : "";
|
|
13221
|
+
const error = new AxiError(
|
|
13222
|
+
`${payload.error || `Lavish Editor request failed: ${response.status}`}${holderDetail}`,
|
|
13223
|
+
payload.code,
|
|
13224
|
+
["Use --takeover only when you intend to displace the current listener"]
|
|
13225
|
+
);
|
|
13226
|
+
if (holder) Object.assign(error, { holder });
|
|
13227
|
+
throw error;
|
|
13228
|
+
}
|
|
13019
13229
|
throw new AxiError(`Lavish Editor request failed: ${response.status}`, "SERVER_ERROR");
|
|
13020
13230
|
}
|
|
13021
13231
|
onResponse?.(response);
|
|
@@ -13114,7 +13324,7 @@ async function readAgentReplyStream(stream) {
|
|
|
13114
13324
|
chunks.push(chunk);
|
|
13115
13325
|
}
|
|
13116
13326
|
const text = Buffer.concat(chunks, bytes).toString("utf8");
|
|
13117
|
-
if (Buffer.byteLength(JSON.stringify({ text })) > AGENT_REPLY_JSON_LIMIT_BYTES) {
|
|
13327
|
+
if (Buffer.byteLength(JSON.stringify({ agent_reply: text })) > AGENT_REPLY_JSON_LIMIT_BYTES) {
|
|
13118
13328
|
throw agentReplyTooLargeError();
|
|
13119
13329
|
}
|
|
13120
13330
|
return text;
|
|
@@ -13197,7 +13407,7 @@ function createTopLevelHelp({ agent = "generic" } = {}) {
|
|
|
13197
13407
|
Usage:
|
|
13198
13408
|
lavish-axi
|
|
13199
13409
|
lavish-axi <html-file> [--no-open] [--no-gate] [--reopen]
|
|
13200
|
-
lavish-axi poll <html-file> [--agent-reply "..."] [--agent-reply-file <path>]
|
|
13410
|
+
lavish-axi poll <html-file> [--owner <label>] [--takeover] [--agent-reply "..."] [--agent-reply-file <path>]
|
|
13201
13411
|
lavish-axi end <html-file>
|
|
13202
13412
|
lavish-axi export <html-file> [--out <path>]
|
|
13203
13413
|
lavish-axi share <html-file> [--private | --password <pw>] [--token <t>]
|
|
@@ -13221,7 +13431,9 @@ function createCommandHelp({ agent = "generic" } = {}) {
|
|
|
13221
13431
|
|
|
13222
13432
|
Open or resume a Lavish Editor review session for an HTML artifact. Use --no-open when you need to ensure the server/session exists without opening another browser window. Use --no-gate to skip the open-time layout curtain for this browser open. If the user explicitly ended the session from the browser, this refuses to reopen it and returns guidance instead - pass --reopen to force it open when the user asks for further review or something important needs their visual attention. Sessions ended by the agent (\`lavish-axi end\`) reopen normally without the flag.
|
|
13223
13433
|
`,
|
|
13224
|
-
poll: `Usage: lavish-axi poll <html-file> [--agent-reply "..."] [--agent-reply-file <path>]
|
|
13434
|
+
poll: `Usage: lavish-axi poll <html-file> [--owner <label>] [--takeover] [--agent-reply "..."] [--agent-reply-file <path>]
|
|
13435
|
+
|
|
13436
|
+
This command exclusively long-polls for queued user prompts. Pass --owner <label> to make the active listener visible in session listings; --takeover displaces an existing listener, which receives LISTENER_REPLACED. A second poll without --takeover fails with LISTENER_ACTIVE instead of silently returning waiting.
|
|
13225
13437
|
|
|
13226
13438
|
This command long-polls indefinitely for queued user prompts. It stays silent while it waits - that is normal, never kill it. Browser-detected layout issues do NOT return this poll: they are filed passively in the user's Layout issues inbox and arrive as an ordinary tag "layout-warnings" prompt only after the user selects them and queues the fixes. Warning lifecycle: an issue stays unresolved and counted while queued, becomes recurring if a newer artifact revision still shows it, and is resolved only after a newer artifact load plus a complete diagnostic pass at the same viewport no longer detects it. A failed or incomplete pass preserves it as unverified rather than clearing it. The only response that arrives without user action is artifact_failures - a fatal failure that made the review surface itself unusable. Do not pass --timeout-ms during normal agent use; it is for tests and debugging only. In a Herdr-managed pane, set LAVISH_AXI_HERDR_CHIME=1 to request attention when the poll has entered its waiting state; unset it or use any other value to keep the current silent behavior. Notification failures never interrupt the poll. ${pollExecutionGuidance({ agent })} Use --agent-reply after applying prior feedback to display a concise response in Lavish Editor before waiting again. ${POLL_AGENT_REPLY_RULE} ${POLL_AGENT_REPLY_HELP_POINTER} Do not combine --agent-reply with --agent-reply-file.
|
|
13227
13439
|
|
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.75",
|
|
5
5
|
"description": "HTML is the new markdown. Lavish is the new editor for your HTML artifacts.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Kun Chen",
|