zigmini 0.8.0-beta.5 → 0.8.0-beta.7

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 (57) hide show
  1. package/README.ko.md +78 -19
  2. package/README.md +79 -19
  3. package/assets/agent/gateway-request-rules.md +1 -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 +25 -2
  9. package/docs/manual.md +25 -2
  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 +171 -8
  17. package/host/omo-check/patches/omo-5.1.35-phantom-attach.patch +31 -0
  18. package/host/omo-check/patches/omo-5.1.35-request-rules.patch +109 -0
  19. package/host/omo-check/patches/omo-5.1.35-turn-stats.patch +76 -0
  20. package/package.json +4 -1
  21. package/scripts/secret-rules.ts +101 -0
  22. package/scripts/secret-scan.allow.json +155 -0
  23. package/scripts/secret-scan.rules.json +30 -0
  24. package/src/agent-request-rules.ts +82 -0
  25. package/src/cli/access.ts +31 -34
  26. package/src/cli/launchd.ts +30 -4
  27. package/src/cli/main.ts +13 -39
  28. package/src/cli/messages.ts +160 -0
  29. package/src/cli/pair.ts +32 -25
  30. package/src/cli/rotate-main.ts +411 -0
  31. package/src/cli/setup.ts +164 -75
  32. package/src/decisions.ts +20 -1
  33. package/src/hub.ts +140 -30
  34. package/src/main.ts +45 -4
  35. package/src/omo/client.ts +28 -0
  36. package/src/protocol.ts +33 -1
  37. package/src/reply-notifications.ts +144 -0
  38. package/src/runtime/paths.ts +2 -0
  39. package/src/security-check.ts +241 -0
  40. package/src/store.ts +71 -1
  41. package/src/turn-stats.ts +49 -0
  42. package/src/work-watch.ts +36 -10
  43. package/src/work.ts +29 -4
  44. package/web/app.js +197 -10
  45. package/web/decisions.js +3 -0
  46. package/web/gc.d.ts +2 -0
  47. package/web/gc.js +11 -0
  48. package/web/index.html +26 -10
  49. package/web/notifications.d.ts +25 -0
  50. package/web/notifications.js +118 -0
  51. package/web/style.css +16 -0
  52. package/web/tooltip.d.ts +0 -6
  53. package/web/tooltip.js +8 -11
  54. package/web/turn-stats.d.ts +26 -0
  55. package/web/turn-stats.js +58 -0
  56. package/web/update.d.ts +1 -1
  57. package/web/update.js +10 -3
package/src/hub.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { scopedActionId, type DialogAnswer, type DialogGate, type DialogMethod, type DialogRequest, type DialogResolution } from "./approvals.ts";
2
2
  import type { DecisionService } from "./decisions.ts";
3
3
  import type { SttProvider } from "./adapters/voice/stt.ts";
4
- import { OmoCommandError, type OmoEvent, type OmoStatus } from "./omo/client.ts";
4
+ import { blockingCompaction, compactionOutcome, OmoCommandError, type CompactionFailureKind, type OmoEvent, type OmoStatus } from "./omo/client.ts";
5
5
  import type { OmoRpc } from "./omo/generations.ts";
6
6
  import { type ActionAnswerData, type ConversationActivity, type ConversationInfo, type Envelope, type MessageReference, PROTOCOL_VERSION, type Source, type Trust } from "./protocol.ts";
7
7
  import { readFileSync, statSync } from "node:fs";
@@ -13,6 +13,9 @@ import { isVersionCommand, VERSION_COMMAND } from "./version.ts";
13
13
  import { isUsageCommand, USAGE_COMMAND } from "./usage.ts";
14
14
  import { MessageReferenceError, referencedInput, resolveMessageReferences } from "./message-references.ts";
15
15
  import { readProfile } from "./profile.ts";
16
+ import type { MessageGuard } from "./security-check.ts";
17
+ import { nativeReplyEntry, type NativeReplyObserver } from "./reply-notifications.ts";
18
+ import { messageTimestamp, nativeTurnStarted, nativeTurnStats } from "./turn-stats.ts";
16
19
 
17
20
  export type Broadcast = (envelope: Envelope) => void;
18
21
 
@@ -35,6 +38,7 @@ const HISTORY_MAX_PAGE = 500;
35
38
  const HISTORY_MAX_RESUME = 1000;
36
39
 
37
40
  export interface HubOptions {
41
+ replies?: NativeReplyObserver;
38
42
  cwd: string;
39
43
  sessionDir: string;
40
44
  permissionPreset: string;
@@ -59,12 +63,30 @@ export interface HubOptions {
59
63
  versionReport?: () => Promise<string>;
60
64
  /** Answers `/usage` (model account usage) as a notice; the command never reaches omo. */
61
65
  usageReport?: () => Promise<string>;
66
+ /** Improvement 165: the security check of user text; a finding keeps the text from omo and the store. */
67
+ guard?: Pick<MessageGuard, "check" | "hold">;
62
68
  }
63
69
 
64
70
  /** Anthropic rejects images over 5 MB; bigger attachments reach the agent as a file path only. */
65
71
  const PROMPT_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
66
72
  const MAX_ATTACHMENTS = 4;
67
73
 
74
+ const COMPACTION_RECOVERY = {
75
+ invalid_model: { action: "settings", message: "요약 모델 설정을 확인하거나 compaction.model을 auto로 바꾼 뒤 다시 진행해 주세요." },
76
+ auth_missing: { action: "login", message: "요약에 사용하는 제공자에 로그인한 뒤 다시 진행해 주세요." },
77
+ auth_expired: { action: "login", message: "요약에 사용하는 제공자에 다시 로그인한 뒤 다시 진행해 주세요." },
78
+ quota: { action: "quota", message: "제공자의 사용 한도가 복구된 뒤 다시 진행해 주세요." },
79
+ org_denied: { action: "account", message: "해당 제공자의 계정과 조직 권한을 확인한 뒤 다시 진행해 주세요." },
80
+ model_unavailable: { action: "settings", message: "요약 모델의 사용 가능 여부와 설정을 확인한 뒤 다시 진행해 주세요." },
81
+ refused: { action: "retry", message: "제공자가 요약을 거절했습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
82
+ timeout: { action: "retry", message: "요약 요청 시간이 초과됐습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
83
+ incomplete: { action: "retry", message: "요약 응답이 완전하지 않습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
84
+ empty: { action: "retry", message: "요약 응답이 비어 있습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
85
+ context_budget: { action: "retry", message: "요약 결과가 대화 모델의 입력 한도에 맞지 않습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
86
+ stale: { action: "retry", message: "요약 중 대화가 바뀌었습니다. 최신 대화에서 다시 진행해 주세요." },
87
+ provider: { action: "retry", message: "원인을 해결한 뒤 다시 진행해 주세요." },
88
+ } as const satisfies Record<CompactionFailureKind, { readonly action: string; readonly message: string }>;
89
+
68
90
  export type RotationReason = "manual" | "age" | ForcedRotationReason;
69
91
  /** Bug 72 proposal 4: limits that rotate a session even with works, updates, decisions or questions open. */
70
92
  export type ForcedRotationReason = "size" | "compactions";
@@ -176,6 +198,9 @@ interface ConvRuntime {
176
198
  finishRotation: (() => void) | null;
177
199
  itemId: string | null;
178
200
  itemText: string;
201
+ /** Set only by the engine's structured start event; never inferred from arrival timing. */
202
+ statsTurnId: string | null;
203
+ statsMessageIndex: number;
179
204
  }
180
205
 
181
206
  interface ContentPart {
@@ -192,7 +217,7 @@ interface SessionEntry {
192
217
  customType?: string;
193
218
  data?: unknown;
194
219
  details?: { notification_id?: string };
195
- message?: { role?: string; content?: ContentPart[] | string; stopReason?: string; clientInputId?: string };
220
+ message?: { role?: string; content?: ContentPart[] | string; stopReason?: string; errorMessage?: string; clientInputId?: string };
196
221
  }
197
222
 
198
223
  interface EntriesResult {
@@ -342,6 +367,20 @@ export class Hub {
342
367
  this.omo = omo;
343
368
  }
344
369
 
370
+ /** Explicit mode activation snapshots every existing main source before admitting new occurrences. */
371
+ async notificationBaseline(): Promise<void> {
372
+ for (const conv of this.store.listConversations()) {
373
+ if (!conv.threadId) continue;
374
+ const { sessionId } = await this.ensureSession(conv);
375
+ const generation = this.generation;
376
+ const revision = this.options.replies?.revision();
377
+ const result = await this.requireOmo().command<EntriesResult>("get_entries", { sessionId });
378
+ if (generation !== this.generation || this.omoStatus !== "ready" || this.rt(conv.id).sessionId !== sessionId ||
379
+ this.store.getConversation(conv.id)?.threadId !== conv.threadId) throw new Error("reply_baseline_discarded");
380
+ if (revision !== undefined) this.options.replies?.observe({ sessionPath: conv.threadId, conv: conv.id, title: conv.title }, result.entries, revision);
381
+ }
382
+ }
383
+
345
384
  attachDecisions(decisions: DecisionService): void {
346
385
  this.decisions = decisions;
347
386
  }
@@ -706,6 +745,8 @@ export class Hub {
706
745
  this.uiSessionScopes.clear();
707
746
  for (const [conv, rt] of this.runtime) {
708
747
  rt.sessionId = null;
748
+ rt.statsTurnId = null;
749
+ rt.statsMessageIndex = 0;
709
750
  rt.opening = null;
710
751
  rt.running = false;
711
752
  rt.waking = null;
@@ -757,6 +798,10 @@ export class Hub {
757
798
  } else if (this.hasUnfinishedTurn(conv.id)) await this.recoverConversation(current);
758
799
  else if (this.openQuestionCards(conv.id, this.conversationScope(conv.id)).size > 0) await this.ensureSession(current);
759
800
  else if (this.store.unreportedWorkUpdates(conv.id).length > 0 || this.workSummary(conv.id).length > 0) await this.ensureSession(current);
801
+ if (this.options.replies?.settings().mode === "all-replies" && current.threadId) {
802
+ await this.ensureSession(this.store.getConversation(conv.id) ?? current);
803
+ await this.syncLedger(conv.id);
804
+ }
760
805
  // Opening main comes first. A notification ACK or worker reconciliation cannot hold readiness.
761
806
  void this.notifyWorkUpdates(conv.id);
762
807
  } catch (error) {
@@ -770,7 +815,7 @@ export class Hub {
770
815
  }
771
816
 
772
817
  /** `images` are names of files already uploaded to the media folder; unknown names are dropped. */
773
- async sendText(convId: string, text: string, src: Source, trust: Trust, images: string[] = [], referenceIds?: unknown): Promise<void> {
818
+ async sendText(convId: string, text: string, src: Source, trust: Trust, images: string[] = [], referenceIds?: unknown, checked = false): Promise<void> {
774
819
  if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
775
820
  const conv = this.store.getConversation(convId);
776
821
  if (!conv) throw new Error(`unknown_conversation:${convId}`);
@@ -792,6 +837,13 @@ export class Hub {
792
837
  throw new MessageReferenceError("message_reference_images_unavailable");
793
838
  }
794
839
  if (!body && attached.length === 0 && references.length === 0) return;
840
+ // Improvement 165: a finding holds the message in gateway memory. The guard sends the card with the values to the
841
+ // user's screens only (never stored, never in a prompt); the user's "send" runs this call again with checked.
842
+ const found = checked ? null : this.options.guard?.check([prompt.text]);
843
+ if (found) {
844
+ this.options.guard!.hold(convId, "message", found, () => this.sendText(convId, text, src, trust, images, referenceIds, true), body);
845
+ return;
846
+ }
795
847
  const input = this.store.acceptMainUser(convId, {
796
848
  text: body, src, trust, ...(attached.length ? { images: attached } : {}),
797
849
  ...(references.length ? { references } : {}),
@@ -1233,6 +1285,8 @@ export class Hub {
1233
1285
  rt.sessionId = null;
1234
1286
  rt.itemId = null;
1235
1287
  rt.itemText = "";
1288
+ rt.statsTurnId = null;
1289
+ rt.statsMessageIndex = 0;
1236
1290
  rt.notified.clear();
1237
1291
  const to = `${this.options.sessionDir.replace(/\/$/, "")}/zgw-${convId}-${fileStamp(this.now())}-${crypto.randomUUID().slice(0, 8)}.jsonl`;
1238
1292
  this.store.rotateThread(convId, to, new Date(this.now()).toISOString(), retirement.handoff);
@@ -1690,6 +1744,28 @@ export class Hub {
1690
1744
  }
1691
1745
  const rt = this.rt(conv);
1692
1746
  switch (event.type) {
1747
+ case "extension_event": {
1748
+ if (event.name === "zgw.turn_started") {
1749
+ rt.statsTurnId = nativeTurnStarted(event.data);
1750
+ rt.statsMessageIndex = 0;
1751
+ } else if (event.name === "zgw.turn_stats" && rt.durableId) {
1752
+ const measured = nativeTurnStats(event.data);
1753
+ if (!measured) return;
1754
+ const stored = this.store.appendTurnStats(conv, rt.durableId, measured);
1755
+ if (stored) this.publishStored(stored);
1756
+ }
1757
+ return;
1758
+ }
1759
+ case "entry_appended": {
1760
+ // Native emits message_end before persistence; this later event closes that gap.
1761
+ const entry = nativeReplyEntry(event.entry);
1762
+ if (entry && this.options.replies) {
1763
+ this.options.replies.observe({ sessionPath: owner.sessionPath, conv, title: this.store.getConversation(conv)?.title ?? "Reply" },
1764
+ [entry], this.options.replies.revision());
1765
+ return;
1766
+ }
1767
+ return this.options.replies ? this.syncLedger(conv) : undefined;
1768
+ }
1693
1769
  case "agent_start":
1694
1770
  rt.run++;
1695
1771
  rt.running = true;
@@ -1717,7 +1793,7 @@ export class Hub {
1717
1793
  }
1718
1794
  case "message_end": {
1719
1795
  const message = event.message as {
1720
- role?: string; content?: ContentPart[] | string; stopReason?: string; errorMessage?: string; clientInputId?: string;
1796
+ role?: string; content?: ContentPart[] | string; stopReason?: string; errorMessage?: string; clientInputId?: string; timestamp?: unknown;
1721
1797
  customType?: string; details?: { notification_id?: string };
1722
1798
  } | undefined;
1723
1799
  if (message?.role === "user") {
@@ -1739,12 +1815,18 @@ export class Hub {
1739
1815
  return;
1740
1816
  }
1741
1817
  if (message?.role !== "assistant") return;
1818
+ rt.statsMessageIndex++;
1742
1819
  const itemId = rt.itemId ?? this.nextId("m");
1743
1820
  const content = typeof message.content === "string" ? [{ type: "text", text: message.content }] : message.content ?? [];
1744
1821
  const thinking = content.filter((c) => c.type === "thinking").map((c) => c.thinking ?? "").join("\n\n").trim();
1745
1822
  if (thinking) this.emit(conv, "thinking.final", { itemId, text: thinking });
1746
1823
  const text = content.filter((c) => c.type === "text").map((c) => c.text ?? "").join("");
1747
- if (text || rt.itemText) this.emit(conv, "message.final", { itemId, text: text || rt.itemText });
1824
+ if (text || rt.itemText) this.emit(conv, "message.final", {
1825
+ itemId, text: text || rt.itemText,
1826
+ ...(rt.statsTurnId && rt.durableId && messageTimestamp(message.timestamp) &&
1827
+ message.stopReason !== "aborted" && message.stopReason !== "error" && !message.errorMessage
1828
+ ? { engineTurn: { id: rt.statsTurnId, sessionId: rt.durableId, messageTimestamp: message.timestamp, messageIndex: rt.statsMessageIndex } } : {}),
1829
+ });
1748
1830
  if (message.stopReason === "aborted") {
1749
1831
  this.emit(conv, "notice", { message: "작업을 중단했습니다.", level: "info" });
1750
1832
  } else if (message.stopReason === "error" || message.errorMessage) {
@@ -1752,7 +1834,7 @@ export class Hub {
1752
1834
  }
1753
1835
  rt.itemId = null;
1754
1836
  rt.itemText = "";
1755
- return;
1837
+ return this.options.replies ? this.syncLedger(conv) : undefined;
1756
1838
  }
1757
1839
  case "tool_execution_start":
1758
1840
  this.emit(conv, "progress.update", {
@@ -1776,36 +1858,43 @@ export class Hub {
1776
1858
  });
1777
1859
  return;
1778
1860
  }
1779
- case "compaction_start":
1780
- this.emit(conv, "session.compaction", { status: "running", reason: event.reason, requestId: event.requestId });
1861
+ case "compaction_start": {
1862
+ if (!blockingCompaction(event) || this.compactionSeen(conv, event, false)) return;
1863
+ const requestId = typeof event.requestId === "string" ? event.requestId : undefined;
1864
+ this.emit(conv, "session.compaction", { status: "running", requestId });
1865
+ this.emit(conv, "notice", { kind: "compaction-started", level: "info", requestId,
1866
+ message: "대화 내용을 요약하고 있습니다. 완료될 때까지 기다려 주세요." });
1781
1867
  return;
1868
+ }
1782
1869
  case "compaction_end": {
1783
- const failed = typeof event.errorMessage === "string" || event.accepted === false ||
1784
- (event.accepted !== true && (typeof event.result !== "object" || event.result === null));
1870
+ if (!blockingCompaction(event) || this.compactionSeen(conv, event, true)) return;
1871
+ const requestId = typeof event.requestId === "string" ? event.requestId : undefined;
1872
+ const outcome = compactionOutcome(event);
1785
1873
  this.emit(conv, "session.compaction", {
1786
- status: event.aborted === true ? "cancelled" : failed ? "failed" : "completed",
1787
- reason: event.reason,
1788
- requestId: event.requestId,
1789
- accepted: event.accepted,
1790
- error: event.errorMessage,
1791
- rejectionCause: event.rejectionCause,
1874
+ ...outcome, requestId, accepted: outcome.status === "completed",
1792
1875
  });
1793
- if (event.aborted === true) {
1794
- this.emit(conv, "notice", {
1795
- kind: "compaction-cancelled",
1796
- level: "warning",
1797
- message: "컨텍스트 압축이 취소됐습니다. 같은 세션을 유지하며 이전 입력을 자동으로 다시 보내지 않습니다.",
1798
- });
1799
- } else if (failed) {
1800
- this.emit(conv, "error", {
1801
- kind: "compaction-failed",
1802
- message: `컨텍스트 압축 실패: ${event.errorMessage ?? event.rejectionCause ?? "완료 결과가 없습니다"}. 같은 세션을 유지합니다. 원인을 해결한 뒤 계속할 지시를 보내 주세요. 이전 입력은 자동 재전송하지 않습니다.`,
1803
- });
1876
+ switch (outcome.status) {
1877
+ case "cancelled":
1878
+ this.emit(conv, "notice", { kind: "compaction-cancelled", level: "warning", requestId,
1879
+ message: "대화 요약을 취소했습니다. 이전 내용과 같은 세션을 유지합니다. 이전 입력은 자동으로 다시 보내지 않습니다." });
1880
+ break;
1881
+ case "failed": {
1882
+ const recovery = COMPACTION_RECOVERY[outcome.failureKind];
1883
+ this.emit(conv, "error", { kind: "compaction-failed", requestId, failureKind: outcome.failureKind,
1884
+ action: recovery.action, message: `대화 요약에 실패했습니다. 이전 내용은 유지했습니다. ${recovery.message}` });
1885
+ break;
1886
+ }
1887
+ case "completed":
1888
+ this.emit(conv, "notice", { kind: "compaction-completed", level: "info", requestId,
1889
+ message: "대화 요약을 완료했습니다. 같은 세션에서 계속합니다." });
1890
+ break;
1804
1891
  }
1805
1892
  return;
1806
1893
  }
1807
1894
  case "agent_settled": {
1808
1895
  rt.running = false;
1896
+ rt.statsTurnId = null;
1897
+ rt.statsMessageIndex = 0;
1809
1898
  this.publishActivity(conv);
1810
1899
  this.unwatchConversation(conv);
1811
1900
  this.dialogs.cancelScope(owner.scope, "작업이 끝났습니다.");
@@ -1828,6 +1917,8 @@ export class Hub {
1828
1917
  this.releaseScopedUiOwner(owner);
1829
1918
  rt.sessionId = null;
1830
1919
  rt.durableId = null;
1920
+ rt.statsTurnId = null;
1921
+ rt.statsMessageIndex = 0;
1831
1922
  rt.running = false;
1832
1923
  this.publishActivity(conv);
1833
1924
  rt.run++;
@@ -2230,6 +2321,7 @@ export class Hub {
2230
2321
  }
2231
2322
 
2232
2323
  private async readLedger(conv: string): Promise<void> {
2324
+ const replyRevision = this.options.replies?.revision();
2233
2325
  const rt = this.rt(conv);
2234
2326
  const generation = this.generation;
2235
2327
  const sessionId = rt.sessionId;
@@ -2246,7 +2338,10 @@ export class Hub {
2246
2338
  const result = await this.requireOmo().command<EntriesResult>("get_entries", { sessionId, since });
2247
2339
  logLedgerRead(conv, "since", result);
2248
2340
  if (!current()) throw new LedgerReadDiscarded();
2249
- this.store.appendLedger(conv, { sessionPath, durableId, lastEntryId: since }, ledgerEntries(result.entries), result.leafId);
2341
+ const entries = ledgerEntries(result.entries);
2342
+ // Persist notification identity before advancing the native history cursor.
2343
+ if (replyRevision !== undefined) this.options.replies?.observe({ sessionPath, conv, title: this.store.getConversation(conv)?.title ?? "Reply" }, result.entries, replyRevision);
2344
+ this.store.appendLedger(conv, { sessionPath, durableId, lastEntryId: since }, entries, result.leafId);
2250
2345
  return;
2251
2346
  } catch (error) {
2252
2347
  if (!(error instanceof OmoCommandError) || !error.code.startsWith("Entry not found")) throw error;
@@ -2256,10 +2351,13 @@ export class Hub {
2256
2351
  // A full read is the one unbounded response the ledger asks for: one at a time across conversations.
2257
2352
  const rebuild = this.ledgerRebuilds.then(async () => {
2258
2353
  if (!current()) throw new LedgerReadDiscarded();
2354
+ const rebuildReplyRevision = this.options.replies?.revision();
2259
2355
  const result = await this.requireOmo().command<EntriesResult>("get_entries", { sessionId });
2260
2356
  logLedgerRead(conv, "full", result);
2261
2357
  if (!current()) throw new LedgerReadDiscarded();
2262
- this.store.rebuildLedger(conv, sessionPath, durableId, ledgerEntries(result.entries), result.leafId);
2358
+ const entries = ledgerEntries(result.entries);
2359
+ if (rebuildReplyRevision !== undefined) this.options.replies?.observe({ sessionPath, conv, title: this.store.getConversation(conv)?.title ?? "Reply" }, result.entries, rebuildReplyRevision);
2360
+ this.store.rebuildLedger(conv, sessionPath, durableId, entries, result.leafId);
2263
2361
  });
2264
2362
  this.ledgerRebuilds = rebuild.catch(() => undefined);
2265
2363
  await rebuild;
@@ -2361,6 +2459,7 @@ export class Hub {
2361
2459
  permissionPreset: this.options.permissionPreset,
2362
2460
  retain_on_disconnect: true,
2363
2461
  kind: "interactive",
2462
+ promptSurface: "app",
2364
2463
  context: { zgw_conv: conv.id, zgw_role: "main" },
2365
2464
  });
2366
2465
  return { sessionPath, opened };
@@ -2453,7 +2552,7 @@ export class Hub {
2453
2552
  rt = {
2454
2553
  sessionId: null, opening: null, running: false, run: 0, rotating: false, admitting: 0, clientInputIdentity: false, atomicQueuedResume: false, durableId: null,
2455
2554
  waking: null, wake: null, notified: new Set(),
2456
- itemId: null, itemText: "",
2555
+ itemId: null, itemText: "", statsTurnId: null, statsMessageIndex: 0,
2457
2556
  rotationDone: null, finishRotation: null,
2458
2557
  };
2459
2558
  this.runtime.set(conv, rt);
@@ -2470,6 +2569,17 @@ export class Hub {
2470
2569
  this.emit("main", "notice", { kind: "update", level: "info", message });
2471
2570
  }
2472
2571
 
2572
+ /** Persisted lifecycle records also deduplicate a terminal replay after a gateway restart. */
2573
+ private compactionSeen(conv: string, event: OmoEvent, terminal: boolean): boolean {
2574
+ if (typeof event.requestId !== "string") return false;
2575
+ return this.store.eventsAfter(conv, this.store.lastSeq(conv, "session.rotated")).some((stored) => {
2576
+ if (stored.type !== "session.compaction") return false;
2577
+ const data = stored.data;
2578
+ return typeof data === "object" && data !== null && "requestId" in data && data.requestId === event.requestId &&
2579
+ (!terminal || ("status" in data && data.status !== "running"));
2580
+ });
2581
+ }
2582
+
2473
2583
  private emit(conv: string, type: string, data: unknown, persist = true): StoredEvent | null {
2474
2584
  const stored = persist && conv !== "*" ? this.store.appendEvent(conv, type, data) : null;
2475
2585
  if (stored) this.publishStored(stored);
package/src/main.ts CHANGED
@@ -4,6 +4,7 @@ import { join, normalize } from "node:path";
4
4
  import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
5
5
  import { createSttProvider } from "./adapters/voice/stt.ts";
6
6
  import { createTtsProvider, speakableText, splitForTts } from "./adapters/voice/tts.ts";
7
+ import { handleHeld, handleSecurityCheckSetting, MessageGuard, SECURITY_CHECK_PATH, SECURITY_HELD_PATH, SecurityCheckError, defaultChecker } from "./security-check.ts";
7
8
  import { handleVoiceInstall, handleVoiceSetting, routeVoiceApi, VOICE_INSTALL_PATH, VOICE_SETTING_PATH, voiceGate, voiceUpdated } from "./voice-api.ts";
8
9
  import { readVoiceSetting, VoiceSetting } from "./voice-setting.ts";
9
10
  import { applyVoiceAgents } from "./voice/agents.ts";
@@ -44,8 +45,9 @@ import { activeRecord, readRegistry, slotUrl } from "./runtime/slots.ts";
44
45
  import { NATIVE_LIFECYCLE_PREFIX } from "./omo/lifecycle.ts";
45
46
  import { RECOVERY_RECEIPT_PREFIX } from "./omo/recovery-receipt.ts";
46
47
  import { fileScheduleSource, notionRoutineSource, ReminderLoop } from "./schedule/reminders.ts";
47
- import { type ClientMessage, type Envelope, PROTOCOL_VERSION, type Source } from "./protocol.ts";
48
+ import { type ClientMessage, type Envelope, PROTOCOL_VERSION, ROTATION_HELD_MESSAGE, type Source } from "./protocol.ts";
48
49
  import { QuestionNotifications } from "./question-notifications.ts";
50
+ import { NotificationSettingsApi, ReplyNotifications } from "./reply-notifications.ts";
49
51
  import { DecisionStoreError, Store } from "./store.ts";
50
52
  import { TranscriptLog } from "./transcript.ts";
51
53
  import { WorkRouter } from "./work.ts";
@@ -145,11 +147,18 @@ const updateApi: UpdateApiDeps = {
145
147
  };
146
148
 
147
149
  let publish: (envelope: Envelope) => void = () => {};
150
+ const replyNotifications = new ReplyNotifications<ServerWebSocket<Conn>>(store, (ws, notification) => {
151
+ if (ws.data.deviceId && store.getDevice(ws.data.deviceId)?.status === "approved") ws.send(envelope("reply.notification", notification, notification.conv));
152
+ });
153
+ const messageGuard = new MessageGuard(defaultChecker(), runtimePaths().securityCheckFile, (held) => publish({
154
+ v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(), conv: held.conv, type: "security-check.held", data: held,
155
+ }));
148
156
  const hub = new Hub(
149
157
  store,
150
158
  dialogs,
151
159
  stt,
152
160
  {
161
+ replies: replyNotifications,
153
162
  cwd: config.omoCwd,
154
163
  sessionDir: config.omoSessionDir,
155
164
  permissionPreset: config.omoPermissionPreset,
@@ -164,6 +173,7 @@ const hub = new Hub(
164
173
  build: process.env.ZGW_BUILD_SHA,
165
174
  }),
166
175
  usageReport: createUsageReport({ dir: agentDir(), inContainer: existsSync("/.dockerenv") }),
176
+ guard: messageGuard,
167
177
  },
168
178
  (e) => publish(e),
169
179
  );
@@ -205,7 +215,7 @@ const omo = new GenerationRouter(startup.endpoints, startup.active, {
205
215
  onEvent: (e) => {
206
216
  if (work.onEvent(e)) return;
207
217
  summarizer.onEvent(e, hub.ownsSession(e.sessionId));
208
- hub.onEvent(e);
218
+ void Promise.resolve(hub.onEvent(e)).catch((error) => console.error("native event:", error));
209
219
  },
210
220
  onStatus: (s, d) => {
211
221
  const generation = ++connectionGeneration;
@@ -249,10 +259,12 @@ function releaseRecovery(): void {
249
259
  })().finally(() => { releaseRunning = null; });
250
260
  }
251
261
  const work = new WorkRouter(store, omo, {
262
+ replies: replyNotifications,
252
263
  sessionDir: config.omoSessionDir,
253
264
  permissionPreset: config.omoPermissionPreset,
254
265
  onPublished(event) {
255
266
  hub.publishStored(event);
267
+ replyNotifications.deliver();
256
268
  // A published work report raises that work's `reported` in the work status.
257
269
  workStatus.schedule();
258
270
  },
@@ -285,6 +297,12 @@ const work = new WorkRouter(store, omo, {
285
297
  else hub.handleScopedUiRequest(owner, event);
286
298
  },
287
299
  });
300
+ const notificationSettingsApi = new NotificationSettingsApi(replyNotifications, async () => {
301
+ await hub.notificationBaseline();
302
+ await work.notificationBaseline();
303
+ }, (data) => publish({
304
+ v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(), conv: "*", type: "notifications.updated", data,
305
+ }));
288
306
  hub.attachOmo(omo);
289
307
  const omoCheck = new OmoCheckScheduler(store, {
290
308
  cwd: process.env.ZGW_OMO_CHECK_CWD ?? "",
@@ -302,7 +320,7 @@ const decisions = new DecisionService(store, {
302
320
  }, (data) => publish({
303
321
  v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(),
304
322
  conv: data.decision.ownerConv, type: "decision.updated", data,
305
- }), { onHistory: (event) => hub.publishStored(event) });
323
+ }), { onHistory: (event) => hub.publishStored(event), guard: messageGuard });
306
324
  hub.attachDecisions(decisions);
307
325
  const summarizer = new SpeechSummarizer(omo, {
308
326
  sessionDir: config.omoSessionDir,
@@ -373,6 +391,7 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
373
391
  ws.data.notifyOnly &&
374
392
  msg.type !== "hello" &&
375
393
  msg.type !== "reminder.ack" &&
394
+ msg.type !== "reply.notification.ack" &&
376
395
  msg.type !== "question.notification.ack"
377
396
  ) return;
378
397
  if (
@@ -403,6 +422,7 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
403
422
  if (state.omo === "ready") questionNotifications.sync(snapshot.approvals);
404
423
  if (ws.data.notifyOnly) {
405
424
  if (ws.data.deviceId) questionNotifications.connectDevice(ws, ws.data.deviceId);
425
+ if (ws.data.deviceId) replyNotifications.connectDevice(ws, ws.data.deviceId);
406
426
  for (const r of reminders.pending(Date.now())) ws.send(envelope("reminder", r));
407
427
  return;
408
428
  }
@@ -423,6 +443,10 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
423
443
  case "reminder.ack":
424
444
  if (typeof msg.data?.id === "string") reminders.ack(msg.data.id, Date.now());
425
445
  return;
446
+ case "reply.notification.ack":
447
+ if (ws.data.notifyOnly && ws.data.deviceId && typeof msg.data?.id === "string" &&
448
+ store.getDevice(ws.data.deviceId)?.status === "approved") replyNotifications.ack(ws, msg.data.id);
449
+ return;
426
450
  case "question.notification.ack": {
427
451
  const id = ws.data.deviceId;
428
452
  if (
@@ -461,6 +485,9 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
461
485
  ws.send(envelope("error", { message: "approval_not_pending", actionId: msg.data.actionId }, msg.conv));
462
486
  }
463
487
  } catch (error) {
488
+ if (error instanceof SecurityCheckError) {
489
+ return;
490
+ }
464
491
  if (!(error instanceof DecisionStoreError)) throw error;
465
492
  ws.send(envelope("error", {
466
493
  message: error.code, actionId: msg.data.actionId,
@@ -475,7 +502,7 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
475
502
  return;
476
503
  case "conversation.rotate":
477
504
  await hub.rotateSession(msg.conv, "manual").catch((e) => {
478
- const message = e instanceof Error && e.message === "turn_running" ? "진행 중이거나 확인되지 않은 작업이 있어 같은 세션을 유지합니다." : String(e);
505
+ const message = e instanceof Error && e.message === "turn_running" ? ROTATION_HELD_MESSAGE : String(e);
479
506
  ws.send(envelope("error", { message }, msg.conv));
480
507
  });
481
508
  return;
@@ -610,6 +637,7 @@ async function handle(req: Request, srv: Server<Conn>, trusted: boolean): Promis
610
637
  return srv.upgrade(req, { data: { trusted, deviceId, notifyOnly, webClient: false } }) ? undefined : new Response("upgrade failed", { status: 400 });
611
638
  }
612
639
  // Voice off answers 503 before native admission and before any body is read.
640
+ if (url.pathname === "/api/settings/notifications") return notificationSettingsApi.handle(req);
613
641
  const voiceDisabled = voiceGate(req, url, voiceSetting);
614
642
  if (voiceDisabled) return voiceDisabled;
615
643
  if (url.pathname === VOICE_INSTALL_PATH) {
@@ -623,6 +651,18 @@ async function handle(req: Request, srv: Server<Conn>, trusted: boolean): Promis
623
651
  });
624
652
  if (response) return response;
625
653
  }
654
+ if (url.pathname === SECURITY_HELD_PATH || url.pathname.startsWith(`${SECURITY_HELD_PATH}/`)) {
655
+ const response = await handleHeld(req, url, messageGuard, (id, outcome) => publish({
656
+ v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(), conv: "*", type: "security-check.resolved", data: { id, outcome },
657
+ }));
658
+ if (response) return response;
659
+ }
660
+ if (url.pathname === SECURITY_CHECK_PATH) {
661
+ const response = await handleSecurityCheckSetting(req, trusted, messageGuard, (enabled) => publish({
662
+ v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(), conv: "*", type: "security-check.updated", data: { enabled },
663
+ }));
664
+ if (response) return response;
665
+ }
626
666
  if (url.pathname === PROFILE_ICON_PATH) {
627
667
  const response = await handleProfileIcon(req, url, trusted, { icon: profileIcon, profile: () => readProfile(store), publish: (e) => publish(e) });
628
668
  if (response) return response;
@@ -827,6 +867,7 @@ const websocket: WebSocketHandler<Conn> = {
827
867
  close(ws) {
828
868
  if (ws.data.deviceId) deviceSockets.get(ws.data.deviceId)?.delete(ws);
829
869
  questionNotifications.disconnectDevice(ws);
870
+ replyNotifications.disconnectDevice(ws);
830
871
  questionNotifications.disconnectWeb(ws);
831
872
  },
832
873
  };
package/src/omo/client.ts CHANGED
@@ -10,6 +10,34 @@ export interface OmoEvent {
10
10
  [key: string]: unknown;
11
11
  }
12
12
 
13
+ /** Public S3 terminal categories. Native diagnostics never cross the UI boundary. */
14
+ export const COMPACTION_FAILURE_KINDS = [
15
+ "invalid_model", "auth_missing", "auth_expired", "quota", "org_denied", "model_unavailable",
16
+ "refused", "timeout", "incomplete", "empty", "context_budget", "stale", "provider",
17
+ ] as const;
18
+ export type CompactionFailureKind = typeof COMPACTION_FAILURE_KINDS[number];
19
+ export type CompactionOutcome =
20
+ | { readonly status: "failed"; readonly failureKind: CompactionFailureKind }
21
+ | { readonly status: "cancelled" | "completed" };
22
+
23
+ export function blockingCompaction(event: OmoEvent): boolean {
24
+ return event.blocking !== false && event.reason !== "speculative" && event.reason !== "idle";
25
+ }
26
+
27
+ /** Old engines have no structured failure; do not classify their raw error strings. */
28
+ export function compactionOutcome(event: OmoEvent): CompactionOutcome {
29
+ const failure = event.failure;
30
+ const structured = typeof failure === "object" && failure !== null;
31
+ if (event.aborted === true && !structured && event.rejectionCause !== "cancelled-by-extension") return { status: "cancelled" };
32
+ const result = typeof event.result === "object" && event.result !== null && !Array.isArray(event.result);
33
+ const failed = structured || typeof event.errorMessage === "string" || event.accepted === false ||
34
+ (event.accepted !== true && !result);
35
+ if (!failed) return { status: "completed" };
36
+ const kind = structured && "kind" in failure ? failure.kind : undefined;
37
+ for (const known of COMPACTION_FAILURE_KINDS) if (kind === known) return { status: "failed", failureKind: known };
38
+ return { status: "failed", failureKind: "provider" };
39
+ }
40
+
13
41
  export interface OmoResponse {
14
42
  type: "response";
15
43
  id: string;
package/src/protocol.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export const PROTOCOL_VERSION = 1;
2
2
 
3
+ /** The `error` message a refused `conversation.rotate` gets when a turn or an open wait holds the session (hub `turn_running`). */
4
+ export const ROTATION_HELD_MESSAGE = "진행 중이거나 확인되지 않은 작업이 있어 같은 세션을 유지합니다.";
5
+
3
6
  export type Channel = "web" | "app" | "home" | "hud" | "discord";
4
7
  export type Trust = "owner" | "owner-voice" | "unknown";
5
8
 
@@ -16,6 +19,22 @@ export interface MessageReference {
16
19
  readonly images?: readonly string[];
17
20
  }
18
21
 
22
+ /** Measured across one native response turn, including its model calls around tools. */
23
+ export interface TurnStats {
24
+ readonly tokensPerSecond?: number;
25
+ /** Sum of assistant/model processing intervals, not tool or total wall time. */
26
+ readonly modelDurationMs?: number;
27
+ /** Missing cache usage is unknown, not zero. */
28
+ readonly cacheHitPercent?: number;
29
+ }
30
+
31
+ export interface MessageStatsData {
32
+ readonly itemId: string;
33
+ readonly messageSeq: number;
34
+ readonly turnId: string;
35
+ readonly stats: TurnStats;
36
+ }
37
+
19
38
  export interface Envelope<T extends string = string, D = unknown> {
20
39
  v: typeof PROTOCOL_VERSION;
21
40
  id: string;
@@ -115,6 +134,18 @@ export interface DecisionUpdatedData {
115
134
  readonly counters: DecisionCountersInfo;
116
135
  }
117
136
 
137
+ export type NotificationMode = "inbox-only" | "all-replies";
138
+ export interface NotificationSettings {
139
+ readonly mode: NotificationMode;
140
+ }
141
+ export interface ReplyNotification {
142
+ readonly id: string;
143
+ readonly conv: string;
144
+ readonly title: string;
145
+ readonly text: string;
146
+ readonly workId?: string;
147
+ }
148
+
118
149
  export type ClientMessage =
119
150
  | { type: "hello"; data: { client: string; version: string } }
120
151
  | { type: "client.presence"; data: { visible: boolean } }
@@ -125,4 +156,5 @@ export type ClientMessage =
125
156
  | { type: "input.interrupt"; conv: string; data: Record<string, never> }
126
157
  | { type: "conversation.rotate"; conv: string }
127
158
  | { type: "reminder.ack"; data: { id: string } }
128
- | { type: "question.notification.ack"; data: { id: string } };
159
+ | { type: "question.notification.ack"; data: { id: string } }
160
+ | { type: "reply.notification.ack"; data: { id: string } };