zigmini 0.8.0-beta.6 → 0.9.0-beta.1

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 (62) hide show
  1. package/README.ko.md +90 -19
  2. package/README.md +91 -19
  3. package/assets/agent/gateway-request-rules.md +3 -0
  4. package/assets/agent/gateway-rules.md +6 -0
  5. package/assets/android/zigmini.apk +0 -0
  6. package/assets/android/zigmini.apk.json +3 -3
  7. package/assets/build.json +1 -1
  8. package/docs/manual.ko.md +69 -1
  9. package/docs/manual.md +67 -1
  10. package/host/omo-bridge/task-control.ts +4 -0
  11. package/host/omo-check/patches/omo-5.1.35-compaction-idle.patch +77 -0
  12. package/host/omo-check/patches/omo-5.1.35-compaction-intent.patch +212 -0
  13. package/host/omo-check/patches/omo-5.1.35-compaction-model.patch +299 -0
  14. package/host/omo-check/patches/omo-5.1.35-compaction-preservation.patch +1063 -0
  15. package/host/omo-check/patches/omo-5.1.35-compaction-refusal.patch +83 -0
  16. package/host/omo-check/patches/omo-5.1.35-gateway-port.manifest.json +169 -7
  17. package/host/omo-check/patches/omo-5.1.35-request-rules.patch +109 -0
  18. package/host/omo-check/patches/omo-5.1.35-turn-stats.patch +76 -0
  19. package/host/omo-check/patches/omo-5.1.44-compaction-port.patch +2630 -0
  20. package/host/omo-check/patches/omo-5.1.44-gateway-port.manifest.json +329 -0
  21. package/host/omo-check/patches/omo-5.1.44-provider-port.patch +440 -0
  22. package/host/omo-check/patches/omo-5.1.44-rpc-port.patch +778 -0
  23. package/package.json +4 -1
  24. package/scripts/secret-rules.ts +101 -0
  25. package/scripts/secret-scan.allow.json +155 -0
  26. package/scripts/secret-scan.rules.json +30 -0
  27. package/src/agent-request-rules.ts +82 -0
  28. package/src/cli/access.ts +31 -34
  29. package/src/cli/launchd.ts +30 -4
  30. package/src/cli/main.ts +3 -43
  31. package/src/cli/messages.ts +160 -0
  32. package/src/cli/pair.ts +32 -25
  33. package/src/cli/setup.ts +164 -75
  34. package/src/decisions.ts +48 -10
  35. package/src/engine/compat.json +3 -3
  36. package/src/hub.ts +183 -47
  37. package/src/main.ts +60 -4
  38. package/src/notification-history.ts +56 -0
  39. package/src/omo/client.ts +28 -0
  40. package/src/protocol.ts +32 -2
  41. package/src/reply-notifications.ts +144 -0
  42. package/src/runtime/paths.ts +2 -0
  43. package/src/security-allow.ts +126 -0
  44. package/src/security-check.ts +559 -0
  45. package/src/store.ts +236 -6
  46. package/src/turn-stats.ts +49 -0
  47. package/src/work-watch.ts +36 -10
  48. package/src/work.ts +45 -6
  49. package/web/DESIGN.md +39 -0
  50. package/web/app.js +289 -2
  51. package/web/decisions.d.ts +1 -1
  52. package/web/decisions.js +6 -0
  53. package/web/diagram.d.ts +28 -0
  54. package/web/diagram.js +228 -0
  55. package/web/index.html +29 -0
  56. package/web/notifications.d.ts +25 -0
  57. package/web/notifications.js +118 -0
  58. package/web/security-inbox.d.ts +35 -0
  59. package/web/security-inbox.js +237 -0
  60. package/web/style.css +43 -1
  61. package/web/turn-stats.d.ts +26 -0
  62. package/web/turn-stats.js +58 -0
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";
@@ -28,6 +29,7 @@ import { hasTextSelection, panelShortcut } from "./shortcuts.js";
28
29
  import { decisionCommand, lastSentLabel, lastSentTooltip, nextLastSent } from "./lastsent.js";
29
30
  import { installTooltips } from "./tooltip.js";
30
31
  import { placeRow, unseenEvents } from "./chatorder.js";
32
+ import { createTurnStats } from "./turn-stats.js";
31
33
  import { cardsOfDecision } from "./cardbind.js";
32
34
  import { createRecall, recallKey, resetRecall } from "./recall.js";
33
35
  import {
@@ -42,8 +44,10 @@ import {
42
44
  import { createEnrollment, NO_DEVICES_LINE, REVOKE_ARMED_LABEL, REVOKE_LABEL } from "./enroll.js";
43
45
  import { canUpdate, checkFailedLine, noticeLine, parseNotice, progressLine, questionGate, resultLine, UPDATE_CHECK_PATH, UPDATE_CONSENT_PATH, UPDATE_PATH, UPDATE_STATE_PATH, UPDATE_STATUS_PATH, updateStep, updateView } from "./update.js";
44
46
  import { createAppDownload } from "./app-download.js";
47
+ import { setupNotificationSettings } from "./notifications.js";
45
48
  import { cleanRequest, cleanResultLine, GC_CLEAN_PATH, GC_PATH, gcView, selectAllState, selectAllTarget } from "./gc.js";
46
49
  import { manualFailureText, manualLinkLang, manualUrl, otherLang } from "./manual.js";
50
+ import { canRestoreHeld, completeDetail, createAllowedValues, createSecurityInbox } from "./security-inbox.js";
47
51
 
48
52
  installTooltips(document, window);
49
53
 
@@ -60,6 +64,7 @@ const state = {
60
64
  // Work activity the gateway reports per conversation (snapshot + activity.updated). It drives the avatar only;
61
65
  // state.running stays the sole input of #interrupt, and history pages never write here.
62
66
  const activity = createActivity();
67
+ const turnStats = createTurnStats();
63
68
  const HISTORY_PAGE = 100;
64
69
  // Loaded window of the current conversation's stored events (by seq), for resuming after a reconnect and paging back.
65
70
  let hist = { conv: null, oldest: null, newest: 0, hasMore: false, loading: false, seen: new Set() };
@@ -533,7 +538,7 @@ function systemLine(text, error = false) {
533
538
 
534
539
  function renderMarkdown(el, text) {
535
540
  el.dataset.raw = text;
536
- el.innerHTML = DOMPurify.sanitize(marked.parse(text, { gfm: true, breaks: true }));
541
+ renderDiagramMarkdown(el, text, marked, DOMPurify);
537
542
  for (const a of el.querySelectorAll("a")) {
538
543
  a.target = "_blank";
539
544
  a.rel = "noopener noreferrer";
@@ -984,8 +989,12 @@ function renderEvent(e, fromHistory = false) {
984
989
  }
985
990
  const source = referenceSource(e);
986
991
  if (source) markReferenceable(body.parentElement, source);
992
+ turnStats.accept(e, body);
987
993
  break;
988
994
  }
995
+ case "message.stats":
996
+ turnStats.accept(e);
997
+ break;
989
998
  case "progress.update":
990
999
  renderProgress(e);
991
1000
  break;
@@ -1096,6 +1105,7 @@ function rememberSent(e) {
1096
1105
 
1097
1106
  function clearMessages(conv) {
1098
1107
  els.messages.innerHTML = "";
1108
+ turnStats.reset(conv);
1099
1109
  setLastSent(null);
1100
1110
  recall.sent.length = 0;
1101
1111
  resetRecall(recall.rec);
@@ -1104,6 +1114,7 @@ function clearMessages(conv) {
1104
1114
  progressEls.clear();
1105
1115
  resolvedSeen.clear();
1106
1116
  hist = { conv, oldest: null, newest: 0, hasMore: false, loading: false, seen: new Set() };
1117
+ renderSecuritySurfaces();
1107
1118
  }
1108
1119
 
1109
1120
  /** `resume`: after a reconnect, keep what is on screen and fetch only the events missed meanwhile. */
@@ -1241,7 +1252,7 @@ function recountPending(approvals) {
1241
1252
  }
1242
1253
 
1243
1254
  function onServerEvent(e, generation, statusGeneration) {
1244
- devLog("in", e);
1255
+ if (e.type !== "security-check.held") devLog("in", e);
1245
1256
  if (e.seq && e.conv === state.current && hist.conv === e.conv && hist.seen.has(e.seq)) return;
1246
1257
  switch (e.type) {
1247
1258
  case "state.snapshot":
@@ -1318,6 +1329,24 @@ function onServerEvent(e, generation, statusGeneration) {
1318
1329
  showUpdateProgress(resultLine(e.data));
1319
1330
  loadUpdateStatus();
1320
1331
  return;
1332
+ case "security-check.updated":
1333
+ applySecurityBody(e.data);
1334
+ return;
1335
+ case "notifications.updated":
1336
+ notificationSettings.updated(e.data);
1337
+ return;
1338
+ case "security-check.held":
1339
+ showHeldCard(e.data);
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;
1347
+ case "security-check.resolved":
1348
+ void securityInbox.detail(e.data.id);
1349
+ return;
1321
1350
  case "voice.updated":
1322
1351
  voice.reads += 1;
1323
1352
  void applyVoiceSetting(voiceEnabledFrom(e.data));
@@ -1643,11 +1672,15 @@ function connect() {
1643
1672
  $("head-avatar").classList.add("live");
1644
1673
  renderActivity();
1645
1674
  inbox.online = true;
1675
+ void securityInbox.connect();
1676
+ void allowedValues.connect();
1646
1677
  renderInboxOnline();
1647
1678
  send({ type: "hello", data: { client: "web-dev", version: "0.1.0" } });
1648
1679
  sendWebPresence();
1649
1680
  resendPendingReportSpeech();
1650
1681
  void readVoiceSetting();
1682
+ void readSecuritySetting();
1683
+ void notificationSettings.connect();
1651
1684
  void readVoiceInstall();
1652
1685
  // Also after an update restarted the gateway: the new version and the update result show without a reload.
1653
1686
  loadUpdateStatus();
@@ -1666,6 +1699,10 @@ function connect() {
1666
1699
  state.sessionTransitions.clear();
1667
1700
  renderSessionTransition();
1668
1701
  inbox.online = false;
1702
+ securityDetailsRequested.clear();
1703
+ securityInbox.disconnect();
1704
+ allowedValues.disconnect();
1705
+ notificationSettings.disconnect();
1669
1706
  historyRequests.length = 0;
1670
1707
  renderInboxOnline();
1671
1708
  setTimeout(connect, 1500);
@@ -2482,6 +2519,237 @@ async function setVoiceEnabled(enabled) {
2482
2519
  voiceButton.onclick = () => setVoiceEnabled(!voice.enabled);
2483
2520
  renderVoiceToggle();
2484
2521
 
2522
+ // ---- Improvement 165: the security check of every user message (settings toggle, blocked draft back in the composer) ----
2523
+ const SECURITY_CHECK_PATH = "/api/settings/security-check";
2524
+ const notificationSettings = setupNotificationSettings({ choice: $("notification-mode"), status: $("notification-status") });
2525
+ const security = { enabled: true, busy: false, read: 0 };
2526
+ const securityButton = $("toggle-security");
2527
+
2528
+ function renderSecurityToggle() {
2529
+ securityButton.textContent = security.enabled ? "보안검사 켜짐" : "보안검사 꺼짐";
2530
+ securityButton.setAttribute("aria-pressed", String(security.enabled));
2531
+ securityButton.classList.toggle("on", security.enabled);
2532
+ }
2533
+
2534
+ function applySecurityBody(body) {
2535
+ if (typeof body?.enabled === "boolean") security.enabled = body.enabled;
2536
+ renderSecurityToggle();
2537
+ }
2538
+
2539
+ async function readSecuritySetting() {
2540
+ const request = ++security.read;
2541
+ const socket = state.ws;
2542
+ try {
2543
+ const res = await fetch(SECURITY_CHECK_PATH, { cache: "no-store" });
2544
+ const body = await res.json();
2545
+ if (res.ok && request === security.read && socket === state.ws && inbox.online) applySecurityBody(body);
2546
+ } catch {
2547
+ // Offline: the reconnect reads it again.
2548
+ }
2549
+ }
2550
+
2551
+ async function setSecurityEnabled(enabled) {
2552
+ if (security.busy) return;
2553
+ security.busy = true;
2554
+ try {
2555
+ const res = await fetch(SECURITY_CHECK_PATH, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled }) });
2556
+ const body = await res.json().catch(() => ({}));
2557
+ if (!res.ok) throw new Error(body.error ?? String(res.status));
2558
+ applySecurityBody(body);
2559
+ } catch (err) {
2560
+ systemLine(`보안검사 설정을 바꾸지 못했습니다: ${err instanceof Error ? err.message : err}`, true);
2561
+ } finally {
2562
+ security.busy = false;
2563
+ }
2564
+ }
2565
+
2566
+ /** A held text comes back into an empty composer of its conversation, so the user can fix it and send again. */
2567
+ function restoreDraft(conv, text) {
2568
+ if (typeof text !== "string" || state.current !== conv || els.input.value !== "" || attach.items.length || refs.state.items.length) return;
2569
+ els.input.value = text;
2570
+ els.input.focus();
2571
+ }
2572
+
2573
+ // Chat and inbox use the same opaque identity and dedicated sensitive detail.
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
+ }
2585
+
2586
+ function showHeldCard(held) {
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 ?? ""));
2600
+ list.append(item);
2601
+ }
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}` : ""}`);
2614
+ status.setAttribute("role", "status");
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));
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;
2655
+ }
2656
+
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
+ }
2688
+ }
2689
+ }
2690
+
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);
2712
+ }
2713
+ return li;
2714
+ }
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
+
2750
+ securityButton.onclick = () => setSecurityEnabled(!security.enabled);
2751
+ renderSecurityToggle();
2752
+
2485
2753
  // ---- Todo 26: an npm install installs the voice servers on demand; the button is hidden everywhere else ----
2486
2754
  const voiceInstallButton = $("voice-install");
2487
2755
 
@@ -2522,6 +2790,7 @@ const memoController = setupMemo({ uploadImage, reportError: (message) => system
2522
2790
  // ---- 확인함: the server's durable decisions (GET /api/decisions), independent of the loaded chat page ----
2523
2791
  const inbox = {
2524
2792
  state: createInbox(),
2793
+ heldAnswers: new Set(),
2525
2794
  open: false,
2526
2795
  loading: false,
2527
2796
  resync: false,
@@ -2627,15 +2896,21 @@ async function syncInbox({ reset = false, cursor = null } = {}) {
2627
2896
  return;
2628
2897
  }
2629
2898
  const counterEpoch = reset ? beginPageReset(inbox.state) : inbox.state.counterEpoch;
2899
+ const generation = decisionGeneration;
2630
2900
  inbox.loading = true;
2631
2901
  inbox.error = null;
2632
2902
  renderInboxStatus();
2633
2903
  try {
2634
2904
  const page = await fetchDecisions(cursor);
2905
+ if (generation !== decisionGeneration) return;
2635
2906
  if (reset) {
2636
2907
  dropSocketSubmissions(inbox.state);
2637
2908
  }
2638
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
+ }
2639
2914
  renderInboxList();
2640
2915
  for (const decision of inbox.state.items.values()) syncCard(decision);
2641
2916
  if (reset) {
@@ -2725,6 +3000,7 @@ function renderInboxList() {
2725
3000
  // The draft and note are shared with the chat card; a mounted card still answering the decision keeps them.
2726
3001
  const shown = decisionById(inbox.state, id);
2727
3002
  if (shown && isUnresolved(shown) && cardForDecision(id)) continue;
3003
+ if (inbox.heldAnswers?.has(id)) continue;
2728
3004
  inbox.forms.delete(id);
2729
3005
  inbox.notes.delete(id);
2730
3006
  }
@@ -2965,6 +3241,7 @@ function renderSourceContext(payload) {
2965
3241
  }
2966
3242
 
2967
3243
  function renderInboxItem(decision) {
3244
+ if (decision.security) return renderSecurityInboxItem(decision);
2968
3245
  const p = presentation(decision);
2969
3246
  const payload = decision.payload && typeof decision.payload === "object" ? decision.payload : {};
2970
3247
  const form = formFor(decision);
@@ -3175,6 +3452,12 @@ function settleSubmit(decision, result) {
3175
3452
  noteConflict(inbox.state, decision, result.decision);
3176
3453
  noteFor(id, "다른 곳에서 먼저 처리됐거나 질문이 바뀌었습니다. 최신 내용을 확인하고 다시 골라 주세요.", true);
3177
3454
  break;
3455
+ case "blocked":
3456
+ // Improvement 165: the answer is held; the card decides. The typed answer stays in the form for a fix.
3457
+ inbox.state.drafts.delete(id);
3458
+ noteFor(id, `${result.message} 채팅의 보안검사 결과 카드에서 보내기 또는 보내지 않기를 고르세요.`, true);
3459
+ if (result.held) showHeldCard(result.held);
3460
+ break;
3178
3461
  case "rejected":
3179
3462
  inbox.state.drafts.delete(id);
3180
3463
  if (result.decision) applyDecision(inbox.state, result.decision);
@@ -3204,6 +3487,10 @@ function onDecisionUpdated(data) {
3204
3487
  const decision = data.decision;
3205
3488
  if (!decision) return;
3206
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
+ }
3207
3494
  renderInboxBadge();
3208
3495
  if (!result.changed) return;
3209
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;
@@ -473,6 +476,9 @@ export function interpretAnswerResponse(status, body) {
473
476
  if (status === 409) return { kind: "conflict", decision, error };
474
477
  if (status === 404) return { kind: "gone", error };
475
478
  if (status === 400) return { kind: "rejected", decision, error };
479
+ if (status === 422 && error === "security_check_blocked") {
480
+ return { kind: "blocked", error, held: record(body.held) ? body.held : null, message: typeof body.message === "string" ? body.message : "보안검사에 걸려 보내지 않고 보류했습니다." };
481
+ }
476
482
  return { kind: "unavailable", status, error };
477
483
  }
478
484
 
@@ -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;