zigmini 0.8.0-beta.7 → 0.9.0-beta.2

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.
Files changed (50) hide show
  1. package/README.ko.md +27 -5
  2. package/README.md +27 -5
  3. package/assets/agent/gateway-request-rules.md +3 -1
  4. package/assets/android/zigmini.apk +0 -0
  5. package/assets/android/zigmini.apk.json +3 -3
  6. package/assets/build.json +1 -1
  7. package/docs/manual.ko.md +57 -4
  8. package/docs/manual.md +55 -4
  9. package/host/omo-bridge/ascii-json.ts +5 -0
  10. package/host/omo-bridge/bridge.ts +50 -44
  11. package/host/omo-check/patches/omo-5.1.44-compaction-port.patch +2630 -0
  12. package/host/omo-check/patches/omo-5.1.44-gateway-port.manifest.json +329 -0
  13. package/host/omo-check/patches/omo-5.1.44-provider-port.patch +440 -0
  14. package/host/omo-check/patches/omo-5.1.44-rpc-port.patch +778 -0
  15. package/host/omo-check/patches/omo-5.1.48-compaction-port.patch +2629 -0
  16. package/host/omo-check/patches/omo-5.1.48-gateway-port.manifest.json +355 -0
  17. package/host/omo-check/patches/omo-5.1.48-provider-port.patch +440 -0
  18. package/host/omo-check/patches/omo-5.1.48-rpc-port.patch +799 -0
  19. package/host/omo-check/patches/omo-5.1.48-runtime-compat.patch +31 -0
  20. package/package.json +1 -1
  21. package/src/cli/setup.ts +6 -2
  22. package/src/decisions.ts +32 -13
  23. package/src/engine/compat.json +4 -4
  24. package/src/engine/install.ts +68 -20
  25. package/src/engine/rpc-socket.ts +36 -0
  26. package/src/engine/schema-guard.ts +34 -2
  27. package/src/hub.ts +57 -31
  28. package/src/main.ts +22 -7
  29. package/src/notification-history.ts +56 -0
  30. package/src/omo/client.ts +16 -3
  31. package/src/omo/frame-socket.ts +330 -0
  32. package/src/protocol.ts +2 -1
  33. package/src/security-allow.ts +126 -0
  34. package/src/security-check.ts +355 -37
  35. package/src/store.ts +165 -5
  36. package/src/update/handoff-system.ts +22 -0
  37. package/src/update/handoff.ts +10 -2
  38. package/src/update/helper.ts +11 -1
  39. package/src/update/request.ts +15 -2
  40. package/src/work.ts +21 -7
  41. package/web/DESIGN.md +39 -0
  42. package/web/app.js +198 -69
  43. package/web/decisions.d.ts +1 -1
  44. package/web/decisions.js +3 -0
  45. package/web/diagram.d.ts +28 -0
  46. package/web/diagram.js +228 -0
  47. package/web/index.html +13 -0
  48. package/web/security-inbox.d.ts +35 -0
  49. package/web/security-inbox.js +237 -0
  50. package/web/style.css +31 -1
package/web/app.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ClapDetector } from "./clap.js";
2
+ import { renderDiagramMarkdown } from "./diagram.js";
2
3
  import { setupMemo } from "./memo.js";
3
4
  import { setupAutoCopy } from "./autocopy.js";
4
5
  import { setupWorkIdCopy } from "./workid.js";
@@ -46,6 +47,7 @@ import { createAppDownload } from "./app-download.js";
46
47
  import { setupNotificationSettings } from "./notifications.js";
47
48
  import { cleanRequest, cleanResultLine, GC_CLEAN_PATH, GC_PATH, gcView, selectAllState, selectAllTarget } from "./gc.js";
48
49
  import { manualFailureText, manualLinkLang, manualUrl, otherLang } from "./manual.js";
50
+ import { canRestoreHeld, completeDetail, createAllowedValues, createSecurityInbox } from "./security-inbox.js";
49
51
 
50
52
  installTooltips(document, window);
51
53
 
@@ -536,7 +538,7 @@ function systemLine(text, error = false) {
536
538
 
537
539
  function renderMarkdown(el, text) {
538
540
  el.dataset.raw = text;
539
- el.innerHTML = DOMPurify.sanitize(marked.parse(text, { gfm: true, breaks: true }));
541
+ renderDiagramMarkdown(el, text, marked, DOMPurify);
540
542
  for (const a of el.querySelectorAll("a")) {
541
543
  a.target = "_blank";
542
544
  a.rel = "noopener noreferrer";
@@ -1112,6 +1114,7 @@ function clearMessages(conv) {
1112
1114
  progressEls.clear();
1113
1115
  resolvedSeen.clear();
1114
1116
  hist = { conv, oldest: null, newest: 0, hasMore: false, loading: false, seen: new Set() };
1117
+ renderSecuritySurfaces();
1115
1118
  }
1116
1119
 
1117
1120
  /** `resume`: after a reconnect, keep what is on screen and fetch only the events missed meanwhile. */
@@ -1249,7 +1252,7 @@ function recountPending(approvals) {
1249
1252
  }
1250
1253
 
1251
1254
  function onServerEvent(e, generation, statusGeneration) {
1252
- devLog("in", e);
1255
+ if (e.type !== "security-check.held") devLog("in", e);
1253
1256
  if (e.seq && e.conv === state.current && hist.conv === e.conv && hist.seen.has(e.seq)) return;
1254
1257
  switch (e.type) {
1255
1258
  case "state.snapshot":
@@ -1335,8 +1338,14 @@ function onServerEvent(e, generation, statusGeneration) {
1335
1338
  case "security-check.held":
1336
1339
  showHeldCard(e.data);
1337
1340
  return;
1341
+ case "security-check.state":
1342
+ securityInbox.merge(e.data);
1343
+ return;
1344
+ case "security-check.allowed-values.updated":
1345
+ allowedValues.updated(e.data.revision);
1346
+ return;
1338
1347
  case "security-check.resolved":
1339
- markHeld(e.data.id, e.data.outcome);
1348
+ void securityInbox.detail(e.data.id);
1340
1349
  return;
1341
1350
  case "voice.updated":
1342
1351
  voice.reads += 1;
@@ -1663,6 +1672,8 @@ function connect() {
1663
1672
  $("head-avatar").classList.add("live");
1664
1673
  renderActivity();
1665
1674
  inbox.online = true;
1675
+ void securityInbox.connect();
1676
+ void allowedValues.connect();
1666
1677
  renderInboxOnline();
1667
1678
  send({ type: "hello", data: { client: "web-dev", version: "0.1.0" } });
1668
1679
  sendWebPresence();
@@ -1670,7 +1681,6 @@ function connect() {
1670
1681
  void readVoiceSetting();
1671
1682
  void readSecuritySetting();
1672
1683
  void notificationSettings.connect();
1673
- void readHeld();
1674
1684
  void readVoiceInstall();
1675
1685
  // Also after an update restarted the gateway: the new version and the update result show without a reload.
1676
1686
  loadUpdateStatus();
@@ -1689,6 +1699,9 @@ function connect() {
1689
1699
  state.sessionTransitions.clear();
1690
1700
  renderSessionTransition();
1691
1701
  inbox.online = false;
1702
+ securityDetailsRequested.clear();
1703
+ securityInbox.disconnect();
1704
+ allowedValues.disconnect();
1692
1705
  notificationSettings.disconnect();
1693
1706
  historyRequests.length = 0;
1694
1707
  renderInboxOnline();
@@ -2509,7 +2522,7 @@ renderVoiceToggle();
2509
2522
  // ---- Improvement 165: the security check of every user message (settings toggle, blocked draft back in the composer) ----
2510
2523
  const SECURITY_CHECK_PATH = "/api/settings/security-check";
2511
2524
  const notificationSettings = setupNotificationSettings({ choice: $("notification-mode"), status: $("notification-status") });
2512
- const security = { enabled: true, busy: false };
2525
+ const security = { enabled: true, busy: false, read: 0 };
2513
2526
  const securityButton = $("toggle-security");
2514
2527
 
2515
2528
  function renderSecurityToggle() {
@@ -2524,9 +2537,12 @@ function applySecurityBody(body) {
2524
2537
  }
2525
2538
 
2526
2539
  async function readSecuritySetting() {
2540
+ const request = ++security.read;
2541
+ const socket = state.ws;
2527
2542
  try {
2528
2543
  const res = await fetch(SECURITY_CHECK_PATH, { cache: "no-store" });
2529
- if (res.ok) applySecurityBody(await res.json());
2544
+ const body = await res.json();
2545
+ if (res.ok && request === security.read && socket === state.ws && inbox.online) applySecurityBody(body);
2530
2546
  } catch {
2531
2547
  // Offline: the reconnect reads it again.
2532
2548
  }
@@ -2549,88 +2565,188 @@ async function setSecurityEnabled(enabled) {
2549
2565
 
2550
2566
  /** A held text comes back into an empty composer of its conversation, so the user can fix it and send again. */
2551
2567
  function restoreDraft(conv, text) {
2552
- if (typeof text !== "string" || state.current !== conv || els.input.value !== "") return;
2568
+ if (typeof text !== "string" || state.current !== conv || els.input.value !== "" || attach.items.length || refs.state.items.length) return;
2553
2569
  els.input.value = text;
2554
2570
  els.input.focus();
2555
2571
  }
2556
2572
 
2557
- // The card is built here from the gateway's held entry and never passes through the engine; values are shown as text.
2573
+ // Chat and inbox use the same opaque identity and dedicated sensitive detail.
2558
2574
  const heldCards = new Map();
2575
+ const securityDetailsRequested = new Set();
2576
+ const securityInbox = createSecurityInbox(fetch, () => renderSecuritySurfaces());
2577
+ const allowedValues = createAllowedValues(fetch, onAllowedValuesChanged);
2578
+ let allowedRemoval = undefined;
2579
+
2580
+ function onAllowedValuesChanged() {
2581
+ // A successful read of the new endpoint establishes support; full canonical detail still gates each approval.
2582
+ securityInbox.supported(allowedValues.state.online && allowedValues.state.ready);
2583
+ renderAllowedValues();
2584
+ }
2559
2585
 
2560
2586
  function showHeldCard(held) {
2561
- if (!held || typeof held.id !== "string" || heldCards.has(held.id)) return;
2562
- const card = document.createElement("div");
2563
- card.className = "msg system security-card";
2564
- card.dataset.heldId = held.id;
2565
- card.setAttribute("role", "group");
2566
- const title = make("div", "security-title", held.source === "answer" ? "보안검사 결과 (확인함 답)" : "보안검사 결과");
2567
- const list = document.createElement("ul");
2568
- list.className = "security-list";
2569
- for (const o of held.occurrences ?? []) {
2570
- const item = document.createElement("li");
2571
- item.append(make("span", "security-kind", o.label), " ", make("code", "security-value", o.value), " ", make("span", "security-place", `${o.line}줄 ${o.column}칸`));
2587
+ if (!securityInbox.merge(held)) return;
2588
+ if (!decisionById(inbox.state, held.id)) void syncInbox({ reset: true });
2589
+ }
2590
+
2591
+ function securityContents(id, surface) {
2592
+ const entry = securityInbox.entries.get(id);
2593
+ const view = securityInbox.view(id);
2594
+ const held = entry.held;
2595
+ const nodes = [make("div", "security-title", held.source === "answer" ? "답변 보안 확인" : "메시지 보안 확인")];
2596
+ const list = make("ul", "security-list");
2597
+ for (const occurrence of held.occurrences ?? []) {
2598
+ const item = make("li");
2599
+ item.append(make("span", "security-kind", occurrence.label), " ", make("code", "security-value", occurrence.canonicalValue ?? ""));
2572
2600
  list.append(item);
2573
2601
  }
2574
- const question = make("p", "security-question", "이 값들을 노출해도 되겠습니까? 보내기를 누르면 원래 메시지를 그대로 엔진에 보냅니다.");
2575
- const actions = make("div", "security-actions");
2576
- const sendButton = make("button", "btn", "보내기");
2577
- const dropButton = make("button", "btn neutral", "보내지 않기");
2578
- sendButton.type = dropButton.type = "button";
2579
- sendButton.onclick = () => void decideHeld(held, true);
2580
- dropButton.onclick = () => void decideHeld(held, false);
2581
- actions.append(sendButton, dropButton);
2582
- const status = make("p", "security-status");
2602
+ nodes.push(list);
2603
+ if (held.status === "pending") nodes.push(make("p", "security-question",
2604
+ "승인하면 원본을 전송하고 위의 정확한 값 전체를 이 설치에 기억합니다. 같은 값은 다음 검사부터 허용합니다. 취소는 이번 전송만 중단하고 기존 작업은 멈추지 않습니다. " +
2605
+ (held.source === "answer" ? "이번 답은 제출하지 않으며 원래 질문과 답변 작성 내용은 그대로 둡니다."
2606
+ : "메시지는 같은 대화의 빈 입력창에만 복구하며 새 초안·첨부·참조는 바꾸지 않습니다.")));
2607
+ const labels = { pending: "선택 대기", processing: "처리 중", sent: "전송 확인", cancelled: "이번 전송 취소", failed: "전송 실패", unconfirmed: "전송 결과 미확인 · 자동 재전송하지 않습니다" };
2608
+ const remembered = view.remembered === null ? "값 기억 결과 미확인" : view.remembered ? "정확한 값 기억됨" : "값 기억 안 됨";
2609
+ const reasons = { allowlist_persist_failed: "허용 값을 저장하지 못했습니다", state_persist_failed: "처리 기록을 확인하지 못했습니다",
2610
+ payload_lost: "보류 원문이 없어 보내지 못했습니다", dispatch_failed: "전송을 시작하지 못했습니다",
2611
+ dispatch_unknown: "전달 여부를 확인하지 못했습니다", original_decision_conflict: "원래 질문이 변경되어 이번 답을 보내지 않았습니다" };
2612
+ const reason = held.errorCode ? reasons[held.errorCode] ?? "상태를 다시 확인하세요" : "";
2613
+ const status = make("p", `security-status${view.status === "sent" ? " confirmed" : ""}`, `${labels[view.status]} · ${remembered}${reason ? ` · ${reason}` : ""}`);
2583
2614
  status.setAttribute("role", "status");
2584
- card.append(title, list, question, actions, status);
2585
- heldCards.set(held.id, { card, held });
2586
- if (held.conv === state.current || held.source === "answer") {
2587
- els.messages.append(card);
2588
- scrollToBottom(true);
2615
+ nodes.push(status);
2616
+ const actions = make("div", "security-actions inbox-actions");
2617
+ const button = (label, cls, enabled, action) => {
2618
+ const b = make("button", `text-btn ${cls}`, label);
2619
+ b.type = "button"; b.disabled = !enabled; b.onclick = action; actions.append(b);
2620
+ };
2621
+ if (held.status === "pending" || view.status === "processing") {
2622
+ button("원본 전송하고 값 기억", "security-approve", view.approve, () => void securityInbox.decide(id, true));
2623
+ button("이번 전송 취소", "security-cancel", view.cancel, () => void securityInbox.decide(id, false));
2589
2624
  }
2625
+ if (view.check || (held.status === "pending" && !completeDetail(held))) {
2626
+ button("상태 확인", "security-refresh", inbox.online && !entry.busy, () => void securityInbox.detail(id));
2627
+ }
2628
+ if (held.status === "pending" && !view.approve && !entry.busy && !entry.uncertain) {
2629
+ nodes.push(make("p", "inbox-note", "게이트웨이의 기억 지원과 전체 값을 확인해야 승인할 수 있습니다."));
2630
+ }
2631
+ if (surface === "inbox" && (view.hide || view.cancel)) {
2632
+ button("삭제", "inbox-dismiss-open", true, () => {
2633
+ if (view.hide) { securityInbox.hide(id); renderInboxList(); return; }
2634
+ inbox.dismissing.add(id);
2635
+ renderInboxItem(decisionById(inbox.state, id)).querySelector(".inbox-dismiss-keep")?.focus();
2636
+ });
2637
+ if (inbox.dismissing.has(id)) {
2638
+ const box = make("div", "inbox-dismiss mode-cancel");
2639
+ box.append(make("p", "inbox-dismiss-text", "이번 전송만 취소합니다. 원래 질문과 답변 작성 내용은 남습니다."));
2640
+ const confirm = make("button", "text-btn danger security-dismiss-confirm", "전송 취소하고 삭제");
2641
+ confirm.disabled = !view.cancel;
2642
+ confirm.onclick = async () => {
2643
+ await securityInbox.decide(id, false);
2644
+ if (securityInbox.view(id).status === "cancelled") securityInbox.hide(id);
2645
+ inbox.dismissing.delete(id);
2646
+ renderInboxList();
2647
+ };
2648
+ const keep = make("button", "text-btn inbox-dismiss-keep", "그대로 두기");
2649
+ keep.onclick = () => { inbox.dismissing.delete(id); renderInboxItem(decisionById(inbox.state, id)); };
2650
+ box.append(confirm, keep); nodes.push(box);
2651
+ }
2652
+ }
2653
+ nodes.push(actions);
2654
+ return nodes;
2590
2655
  }
2591
2656
 
2592
- function markHeld(id, outcome, message) {
2593
- const entry = heldCards.get(id);
2594
- if (!entry) return;
2595
- for (const button of entry.card.querySelectorAll("button")) button.disabled = true;
2596
- const status = entry.card.querySelector(".security-status");
2597
- status.textContent = message ?? (outcome === "sent" ? "보냈습니다." : outcome === "lost" ? "게이트웨이가 다시 시작해 보류 메시지가 사라졌습니다. 엔진에는 보내지 않았습니다." : "보내지 않았습니다. 메시지를 버렸습니다.");
2598
- entry.card.classList.add("done");
2599
- if (outcome !== "sent" && entry.held.source === "message") restoreDraft(entry.held.conv, entry.held.draft);
2600
- }
2601
-
2602
- async function decideHeld(held, send) {
2603
- const entry = heldCards.get(held.id);
2604
- if (!entry || entry.card.classList.contains("done")) return;
2605
- for (const button of entry.card.querySelectorAll("button")) button.disabled = true;
2606
- try {
2607
- const res = await fetch(`/api/security-check/held/${encodeURIComponent(held.id)}`, {
2608
- method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ send }),
2609
- });
2610
- const body = await res.json().catch(() => ({}));
2611
- if (res.status === 404) markHeld(held.id, "lost");
2612
- else if (!res.ok) markHeld(held.id, "failed", `보내지 못했습니다: ${body.message ?? body.error ?? res.status}`);
2613
- else markHeld(held.id, body.outcome);
2614
- } catch (err) {
2615
- for (const button of entry.card.querySelectorAll("button")) button.disabled = false;
2616
- entry.card.querySelector(".security-status").textContent = `연결 오류: ${err instanceof Error ? err.message : err}`;
2657
+ function renderSecuritySurfaces() {
2658
+ for (const [id, entry] of securityInbox.entries) {
2659
+ const held = entry.held;
2660
+ if (held.source === "answer" && held.originalDecisionId) inbox.heldAnswers?.add(held.originalDecisionId);
2661
+ let card = heldCards.get(id);
2662
+ if (securityInbox.hidden.has(id)) {
2663
+ card?.remove();
2664
+ const row = inbox.nodes.get(id);
2665
+ if (row) row.hidden = true;
2666
+ continue;
2667
+ }
2668
+ if (held.conv === state.current || held.source === "answer") {
2669
+ if (!card) {
2670
+ card = make("div", "msg system security-card");
2671
+ card.dataset.heldId = id;
2672
+ card.setAttribute("role", "group");
2673
+ card.setAttribute("aria-label", "보안 확인");
2674
+ heldCards.set(id, card);
2675
+ }
2676
+ card.setAttribute("aria-busy", String(entry.busy));
2677
+ card.replaceChildren(...securityContents(id, "chat"));
2678
+ if (!card.isConnected) els.messages.append(card);
2679
+ } else card?.remove();
2680
+ const decision = decisionById(inbox.state, id);
2681
+ if (decision && inbox.state.items.has(id)) renderInboxItem(decision);
2682
+ if (!entry.restored && (held.status === "cancelled" || held.status === "failed")) {
2683
+ if (canRestoreHeld(held, {
2684
+ conv: state.current, text: els.input.value, images: attach.items.length, references: refs.state.items.length,
2685
+ })) restoreDraft(held.conv, entry.draft);
2686
+ entry.restored = true;
2687
+ }
2617
2688
  }
2618
2689
  }
2619
2690
 
2620
- /** On every connect: cards still held come back; a card whose entry is gone (gateway restart) is marked lost. */
2621
- async function readHeld() {
2622
- try {
2623
- const res = await fetch("/api/security-check/held", { cache: "no-store" });
2624
- if (!res.ok) return;
2625
- const { held } = await res.json();
2626
- const ids = new Set(held.map((h) => h.id));
2627
- for (const [id, entry] of heldCards) if (!ids.has(id) && !entry.card.classList.contains("done")) markHeld(id, "lost");
2628
- for (const h of held) showHeldCard(h);
2629
- } catch {
2630
- // Offline: the next connect reads again.
2691
+ function renderSecurityInboxItem(decision) {
2692
+ const id = decision.id;
2693
+ let entry = securityInbox.entries.get(id);
2694
+ if (!entry) {
2695
+ // The generic row carries state only; complete canonical values come from the dedicated endpoint.
2696
+ entry = { held: decision.security, busy: false, uncertain: false, restored: false };
2697
+ securityInbox.entries.set(id, entry);
2698
+ } else if (decision.security.revision > entry.held.revision) {
2699
+ entry.held = { ...entry.held, ...decision.security };
2700
+ }
2701
+ const li = make("li", "inbox-item security-inbox-item");
2702
+ li.dataset.id = id; li.dataset.heldId = id; li.dataset.revision = String(entry.held.revision);
2703
+ li.hidden = securityInbox.hidden.has(id);
2704
+ li.setAttribute("aria-busy", String(entry.busy));
2705
+ li.append(...securityContents(id, "inbox"));
2706
+ const previous = inbox.nodes.get(id);
2707
+ if (previous?.isConnected) previous.replaceWith(li);
2708
+ inbox.nodes.set(id, li);
2709
+ if (entry.held.status === "pending" && !completeDetail(entry.held) && !securityDetailsRequested.has(id)) {
2710
+ securityDetailsRequested.add(id);
2711
+ void securityInbox.detail(id);
2631
2712
  }
2713
+ return li;
2632
2714
  }
2633
2715
 
2716
+ function renderAllowedValues() {
2717
+ const model = allowedValues.state;
2718
+ const list = $("security-allowed-list");
2719
+ list.replaceChildren();
2720
+ $("security-allowed-status").textContent = !model.online ? "연결이 끊겼습니다." : model.busy ? "삭제 처리 중…" : model.loading ? "불러오는 중…" : model.error || (model.ready && model.values.length === 0 ? "기억한 값이 없습니다." : "");
2721
+ const locked = !model.online || model.loading || model.busy || !model.ready || Boolean(model.error);
2722
+ $("security-allowed-refresh").disabled = !model.online || model.loading || model.busy;
2723
+ $("security-allowed-clear").disabled = locked || model.values.length === 0;
2724
+ for (const value of model.values) {
2725
+ const row = make("li", "security-allowed-item");
2726
+ row.dataset.allowedId = value.id;
2727
+ const client = value.approvedBy.client === "android" ? "Android 앱" : "웹";
2728
+ row.append(make("code", "security-value", value.value), make("div", "inbox-note", `${value.label} · ${dateTimeLabel(value.approvedAt)} · ${client}`));
2729
+ const remove = make("button", "text-btn", "제거");
2730
+ remove.disabled = locked;
2731
+ remove.onclick = () => { allowedRemoval = value.id; renderAllowedValues(); $("security-allowed-keep").focus(); };
2732
+ row.append(remove); list.append(row);
2733
+ }
2734
+ const confirm = $("security-allowed-confirm");
2735
+ confirm.hidden = allowedRemoval === undefined;
2736
+ $("security-allowed-remove").disabled = locked;
2737
+ }
2738
+
2739
+ $("security-allowed-refresh").onclick = () => { allowedRemoval = undefined; void allowedValues.refresh(); };
2740
+ $("security-allowed-clear").onclick = () => { allowedRemoval = null; renderAllowedValues(); $("security-allowed-keep").focus(); };
2741
+ $("security-allowed-keep").onclick = () => { allowedRemoval = undefined; renderAllowedValues(); };
2742
+ $("security-allowed-remove").onclick = async () => {
2743
+ if (allowedRemoval === undefined) return;
2744
+ const id = allowedRemoval;
2745
+ allowedRemoval = undefined;
2746
+ await allowedValues.remove(id);
2747
+ renderAllowedValues();
2748
+ };
2749
+
2634
2750
  securityButton.onclick = () => setSecurityEnabled(!security.enabled);
2635
2751
  renderSecurityToggle();
2636
2752
 
@@ -2674,6 +2790,7 @@ const memoController = setupMemo({ uploadImage, reportError: (message) => system
2674
2790
  // ---- 확인함: the server's durable decisions (GET /api/decisions), independent of the loaded chat page ----
2675
2791
  const inbox = {
2676
2792
  state: createInbox(),
2793
+ heldAnswers: new Set(),
2677
2794
  open: false,
2678
2795
  loading: false,
2679
2796
  resync: false,
@@ -2779,15 +2896,21 @@ async function syncInbox({ reset = false, cursor = null } = {}) {
2779
2896
  return;
2780
2897
  }
2781
2898
  const counterEpoch = reset ? beginPageReset(inbox.state) : inbox.state.counterEpoch;
2899
+ const generation = decisionGeneration;
2782
2900
  inbox.loading = true;
2783
2901
  inbox.error = null;
2784
2902
  renderInboxStatus();
2785
2903
  try {
2786
2904
  const page = await fetchDecisions(cursor);
2905
+ if (generation !== decisionGeneration) return;
2787
2906
  if (reset) {
2788
2907
  dropSocketSubmissions(inbox.state);
2789
2908
  }
2790
2909
  applyPage(inbox.state, page, { reset, counterEpoch });
2910
+ for (const decision of page.decisions) if (decision.security) {
2911
+ securityInbox.merge(decision.security);
2912
+ if (decision.security.status === "pending") void securityInbox.detail(decision.id);
2913
+ }
2791
2914
  renderInboxList();
2792
2915
  for (const decision of inbox.state.items.values()) syncCard(decision);
2793
2916
  if (reset) {
@@ -2877,6 +3000,7 @@ function renderInboxList() {
2877
3000
  // The draft and note are shared with the chat card; a mounted card still answering the decision keeps them.
2878
3001
  const shown = decisionById(inbox.state, id);
2879
3002
  if (shown && isUnresolved(shown) && cardForDecision(id)) continue;
3003
+ if (inbox.heldAnswers?.has(id)) continue;
2880
3004
  inbox.forms.delete(id);
2881
3005
  inbox.notes.delete(id);
2882
3006
  }
@@ -3117,6 +3241,7 @@ function renderSourceContext(payload) {
3117
3241
  }
3118
3242
 
3119
3243
  function renderInboxItem(decision) {
3244
+ if (decision.security) return renderSecurityInboxItem(decision);
3120
3245
  const p = presentation(decision);
3121
3246
  const payload = decision.payload && typeof decision.payload === "object" ? decision.payload : {};
3122
3247
  const form = formFor(decision);
@@ -3362,6 +3487,10 @@ function onDecisionUpdated(data) {
3362
3487
  const decision = data.decision;
3363
3488
  if (!decision) return;
3364
3489
  const result = applyUpdate(inbox.state, data);
3490
+ if (decision.security) {
3491
+ securityInbox.merge(decision.security);
3492
+ if (decision.security.status === "pending") void securityInbox.detail(decision.id);
3493
+ }
3365
3494
  renderInboxBadge();
3366
3495
  if (!result.changed) return;
3367
3496
  if (endSocketSubmit(inbox.state, decision)) setCardBusy(originalActionId(decision), false);
@@ -86,7 +86,7 @@ export function finishDetailLoad(
86
86
  ): DetailResult;
87
87
  export function failDetailLoad(inbox: Inbox, id: string, generation: number): boolean;
88
88
  export function presentation(decision: DecisionInfo): {
89
- readonly kind: "terminal" | "submitted" | "live" | "resume" | "permission" | "recheck";
89
+ readonly kind: "terminal" | "submitted" | "live" | "resume" | "permission" | "recheck" | "security";
90
90
  readonly mode: AnswerMode | null;
91
91
  readonly form: boolean;
92
92
  readonly recheck: boolean;
package/web/decisions.js CHANGED
@@ -338,6 +338,7 @@ export function sourceContext(payload) {
338
338
  }
339
339
 
340
340
  export function presentation(decision) {
341
+ if (decision.security) return { kind: "security", mode: null, form: false, recheck: false, terminal: false };
341
342
  const actions = decision.allowedActions ?? [];
342
343
  const terminal = TERMINAL_WORK.has(decision.work?.status ?? "");
343
344
  let kind = "terminal";
@@ -361,6 +362,7 @@ export function dismissMode(decision) {
361
362
  }
362
363
 
363
364
  export function canDismiss(decision) {
365
+ if (decision.security) return false;
364
366
  return isUnresolved(decision) && decision.status !== "submitted";
365
367
  }
366
368
 
@@ -445,6 +447,7 @@ export function composeResponse(decision, form = {}) {
445
447
 
446
448
  /** An unchanged response replays its pending request verbatim, even after the current presentation mode changes. */
447
449
  export function buildRequest(inbox, decision, mode, response, newId = () => crypto.randomUUID()) {
450
+ if (decision.security) return null;
448
451
  if (inbox.reviewRequired.has(decision.id)) return null;
449
452
  const draft = inbox.drafts.get(decision.id);
450
453
  const payload = response === undefined ? null : response;
@@ -0,0 +1,28 @@
1
+ export interface DiagramNode {
2
+ readonly id: string;
3
+ readonly label: string;
4
+ }
5
+ export interface Diagram {
6
+ readonly version: 1;
7
+ readonly title?: string;
8
+ readonly nodes: readonly DiagramNode[];
9
+ readonly edges: readonly { readonly from: string; readonly to: string }[];
10
+ }
11
+ export interface MarkdownToken {
12
+ readonly type: string;
13
+ readonly raw: string;
14
+ readonly lang?: string;
15
+ }
16
+ export type MarkdownTokens = MarkdownToken[];
17
+ export interface DiagramMarkdown {
18
+ lexer(text: string, options: { readonly gfm: boolean; readonly breaks: boolean }): MarkdownTokens;
19
+ parser(tokens: MarkdownTokens, options: { readonly gfm: boolean; readonly breaks: boolean }): string;
20
+ }
21
+ export type DiagramPart = { readonly html: string } | { readonly diagram: Diagram; readonly source: string };
22
+ export function parseDiagram(body: string): Diagram | null;
23
+ export function diagramLevels(diagram: Diagram): readonly (readonly DiagramNode[])[] | null;
24
+ export function diagramParts(text: string, markdown: DiagramMarkdown): readonly DiagramPart[];
25
+ export function createDiagram(doc: Document, diagram: Diagram, source: string): { readonly element: HTMLElement; dispose(): void };
26
+ export function renderDiagramMarkdown(
27
+ el: HTMLElement, text: string, markdown: DiagramMarkdown, purify: { sanitize(html: string): string },
28
+ ): void;