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/src/decisions.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
2
  import { decisionContext } from "./decision-context.ts";
3
+ import { SecurityCheckError, type MessageGuard } from "./security-check.ts";
4
+ import { securityDecision, SecurityRuntimeError } from "./security-allow.ts";
3
5
  import type { OmoEvent } from "./omo/client.ts";
4
6
  import type { ActionAnswerData, DecisionAnswerRequest, DecisionInfo, DecisionsPageData, DecisionUpdatedData } from "./protocol.ts";
5
7
  import { blockedMainQuestion, blockedQuestion, DecisionStoreError, RESUMABLE_CLOSED, Store, type Decision, type DecisionMethod, type DecisionSubmission, type StoredEvent } from "./store.ts";
@@ -35,6 +37,15 @@ export interface DecisionServiceOptions {
35
37
  readonly receiptTimeoutMs?: number;
36
38
  readonly onHistory?: (event: StoredEvent) => void;
37
39
  readonly onError?: (error: unknown) => void;
40
+ /** Improvement 165: the security check of answer text (value, comment, free text of each question). */
41
+ readonly guard?: Pick<MessageGuard, "holdOrPass">;
42
+ }
43
+
44
+ function answerTexts(request: DecisionAnswerRequest): string[] {
45
+ if (!("response" in request)) return [];
46
+ const response = request.response as { value?: unknown; comment?: unknown; answers?: Record<string, { text?: unknown }> };
47
+ const texts = [response.value, response.comment, ...Object.values(response.answers ?? {}).map((answer) => answer.text)];
48
+ return texts.filter((text): text is string => typeof text === "string");
38
49
  }
39
50
 
40
51
  function record(value: unknown): value is Record<string, unknown> {
@@ -153,6 +164,10 @@ export class DecisionService {
153
164
  }
154
165
 
155
166
  info(decision: Decision): DecisionInfo {
167
+ if (securityDecision(decision)) {
168
+ const security = this.store.securityHold(decision.id)?.state;
169
+ return { ...decision, work: null, allowedActions: [], ...(security ? { security } : {}) };
170
+ }
156
171
  const work = decision.workId === null ? null : this.store.getWork(decision.workId);
157
172
  let allowedActions: DecisionInfo["allowedActions"] = [];
158
173
  if (unresolved(decision) && !["completed", "failed", "cancelled"].includes(work?.status ?? "") &&
@@ -189,8 +204,19 @@ export class DecisionService {
189
204
  return this.info(this.required(id));
190
205
  }
191
206
 
192
- answer(id: string, input: unknown): DecisionInfo {
207
+ answer(id: string, input: unknown, checked = false): DecisionInfo {
208
+ const original = this.store.getDecision(id);
209
+ if (original && securityDecision(original)) throw new DecisionStoreError("security_endpoint_required");
193
210
  const request = parseDecisionAnswer(input);
211
+ if (!checked) {
212
+ const owner = this.store.getDecision(id)?.ownerConv ?? "main";
213
+ const snapshot = structuredClone(request);
214
+ this.options.guard?.holdOrPass(owner, "answer", answerTexts(request), async (receipt) => {
215
+ receipt({ kind: "answer", decisionId: id, submissionId: snapshot.submissionId });
216
+ this.answer(id, snapshot, true);
217
+ }, { key: JSON.stringify(["answer", id, request.submissionId]), snapshot,
218
+ originalDecisionId: id, submissionId: request.submissionId });
219
+ }
194
220
  this.refresh();
195
221
  const before = this.required(id);
196
222
  const binding = this.ports.liveBinding(before);
@@ -212,15 +238,15 @@ export class DecisionService {
212
238
  try {
213
239
  if (!this.ports.sendLive(decision)) this.review(id, "receipt_unknown");
214
240
  } catch (error) {
215
- this.error(error);
241
+ this.error(checked ? new SecurityRuntimeError("dispatch_unknown") : error);
216
242
  this.review(id, "receipt_unknown");
217
243
  }
218
244
  } else if (operation?.data.mainInputSeq !== null && operation?.data.mainInputSeq !== undefined) {
219
245
  const inputEvent = this.store.getEvent(operation.data.mainInputSeq);
220
246
  if (!inputEvent) throw new Error("decision_main_intent_missing");
221
- this.dispatch(id, () => this.ports.dispatchMain(inputEvent));
247
+ this.dispatch(id, () => this.ports.dispatchMain(inputEvent), checked);
222
248
  } else if (operation?.data.commandId && decision.workId) {
223
- this.dispatch(id, () => this.ports.dispatchWork(decision.workId!));
249
+ this.dispatch(id, () => this.ports.dispatchWork(decision.workId!), checked);
224
250
  }
225
251
  this.refresh();
226
252
  return this.info(this.required(id));
@@ -231,6 +257,7 @@ export class DecisionService {
231
257
  * 취소 button; a later answer to the closed decision is rejected as a conflict.
232
258
  */
233
259
  dismiss(id: string, input: unknown): DecisionInfo {
260
+ if (securityDecision(this.required(id))) throw new DecisionStoreError("security_endpoint_required");
234
261
  const expectedRevision = parseDecisionDismiss(input);
235
262
  this.refresh();
236
263
  let dismissal;
@@ -258,9 +285,9 @@ export class DecisionService {
258
285
  return this.info(this.required(id));
259
286
  }
260
287
 
261
- private dispatch(id: string, send: () => Promise<void>): void {
288
+ private dispatch(id: string, send: () => Promise<void>, protectedPayload = false): void {
262
289
  void send().catch((error: unknown) => {
263
- this.error(error);
290
+ this.error(protectedPayload ? new SecurityRuntimeError("dispatch_unknown") : error);
264
291
  if (!this.stopped) this.review(id, "receipt_unknown");
265
292
  }).finally(() => {
266
293
  if (!this.stopped) this.refresh();
@@ -507,6 +534,7 @@ export class DecisionService {
507
534
  this.store.reconcileDecisionReceipts();
508
535
  const now = Date.now();
509
536
  for (let decision of this.store.allDecisions()) {
537
+ if (securityDecision(decision)) continue;
510
538
  if (!unresolved(decision)) continue;
511
539
  const context = this.store.decisionContextReason(decision);
512
540
  if (context === "work_failed" || context === "work_cancelled") {
@@ -534,16 +562,18 @@ export class DecisionService {
534
562
  }
535
563
  }
536
564
  for (const decision of this.store.allDecisions()) {
565
+ if (securityDecision(decision)) continue;
537
566
  if (decision.method === "question" && decision.status === "pending" && decision.bindingState === "live") {
538
567
  this.store.reconcileReissuedQuestion(decision.id, this.ports.liveBinding(decision));
539
568
  }
540
569
  }
570
+ this.store.notifySecurityReceipts();
541
571
  const all = this.store.allDecisions();
542
572
  for (const decision of all) {
543
573
  const previous = this.known.get(decision.id);
544
574
  this.known.set(decision.id, decision);
545
575
  if (!previous || previous.revision < decision.revision) {
546
- if (previous && previous.status !== decision.status && !unresolved(decision)) {
576
+ if (!securityDecision(decision) && previous && previous.status !== decision.status && !unresolved(decision)) {
547
577
  const answer = record(decision.answer) ? decision.answer : {};
548
578
  const kind = decision.method === "question" ? "question" : decision.method === "confirm" ? "confirm" : "value";
549
579
  const dismissed = decision.status === "closed" ? DISMISSED_REASONS[decision.reason ?? ""] : undefined;
@@ -564,7 +594,7 @@ export class DecisionService {
564
594
  }
565
595
  const times: number[] = [];
566
596
  if (unresolved(decision) && decision.bindingState === "live" && decision.deadlineAt !== null) times.push(decision.deadlineAt);
567
- if (decision.status === "submitted") {
597
+ if (!securityDecision(decision) && decision.status === "submitted") {
568
598
  const operation = this.store.latestDecisionOperation(decision.id);
569
599
  times.push(Date.parse(operation?.ts ?? decision.updatedAt) + this.receiptMs);
570
600
  }
@@ -610,7 +640,7 @@ export class DecisionService {
610
640
  }
611
641
  if (!route[2] && req.method === "GET") {
612
642
  const decision = this.required(id);
613
- if (unresolved(decision)) {
643
+ if (!securityDecision(decision) && unresolved(decision)) {
614
644
  let timer: ReturnType<typeof setTimeout> | undefined;
615
645
  try {
616
646
  await Promise.race([
@@ -630,10 +660,18 @@ export class DecisionService {
630
660
  }
631
661
  return Response.json({ error: "method_not_allowed" }, { status: 405 });
632
662
  } catch (error) {
663
+ if (error instanceof SecurityCheckError) {
664
+ return Response.json({ error: error.code, message: error.message, held: error.held }, { status: 422, headers: { "Cache-Control": "no-store" } });
665
+ }
666
+ if (error instanceof SecurityRuntimeError) {
667
+ return Response.json({ error: error.code, ...(error.held ? { held: error.held } : {}) }, {
668
+ status: error.code === "held_conflict" ? 409 : 503, headers: { "Cache-Control": "no-store" },
669
+ });
670
+ }
633
671
  if (error instanceof DecisionStoreError) {
634
672
  return Response.json({
635
673
  error: error.code, decision: error.decision ? this.info(error.decision) : null,
636
- }, { status: error.code === "decision_not_found" ? 404 : error.code.startsWith("invalid_") ? 400 : 409 });
674
+ }, { status: error.code === "decision_not_found" ? 404 : error.code === "security_endpoint_required" || error.code.startsWith("invalid_") ? 400 : 409 });
637
675
  }
638
676
  if (error instanceof URIError) return Response.json({ error: "invalid_decision" }, { status: 400 });
639
677
  this.error(error);
@@ -1,6 +1,6 @@
1
1
  {
2
- "omo": "5.1.35",
3
- "senpi": "2026.10.16",
2
+ "omo": "5.1.44",
3
+ "senpi": "2026.10.21-3",
4
4
  "threadSchema": 7,
5
- "verifiedAt": "2026-10-19"
5
+ "verifiedAt": "2026-10-23"
6
6
  }
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,11 @@ 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 { SecurityRuntimeError, type SecurityReceipt } from "./security-allow.ts";
18
+ import { nativeReplyEntry, type NativeReplyObserver } from "./reply-notifications.ts";
19
+ import { notificationHistory } from "./notification-history.ts";
20
+ import { messageTimestamp, nativeTurnStarted, nativeTurnStats } from "./turn-stats.ts";
16
21
 
17
22
  export type Broadcast = (envelope: Envelope) => void;
18
23
 
@@ -35,6 +40,7 @@ const HISTORY_MAX_PAGE = 500;
35
40
  const HISTORY_MAX_RESUME = 1000;
36
41
 
37
42
  export interface HubOptions {
43
+ replies?: NativeReplyObserver;
38
44
  cwd: string;
39
45
  sessionDir: string;
40
46
  permissionPreset: string;
@@ -59,12 +65,30 @@ export interface HubOptions {
59
65
  versionReport?: () => Promise<string>;
60
66
  /** Answers `/usage` (model account usage) as a notice; the command never reaches omo. */
61
67
  usageReport?: () => Promise<string>;
68
+ /** Improvement 165: the security check of user text; a finding keeps the text from omo and the store. */
69
+ guard?: Pick<MessageGuard, "check" | "hold" | "existing">;
62
70
  }
63
71
 
64
72
  /** Anthropic rejects images over 5 MB; bigger attachments reach the agent as a file path only. */
65
73
  const PROMPT_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
66
74
  const MAX_ATTACHMENTS = 4;
67
75
 
76
+ const COMPACTION_RECOVERY = {
77
+ invalid_model: { action: "settings", message: "요약 모델 설정을 확인하거나 compaction.model을 auto로 바꾼 뒤 다시 진행해 주세요." },
78
+ auth_missing: { action: "login", message: "요약에 사용하는 제공자에 로그인한 뒤 다시 진행해 주세요." },
79
+ auth_expired: { action: "login", message: "요약에 사용하는 제공자에 다시 로그인한 뒤 다시 진행해 주세요." },
80
+ quota: { action: "quota", message: "제공자의 사용 한도가 복구된 뒤 다시 진행해 주세요." },
81
+ org_denied: { action: "account", message: "해당 제공자의 계정과 조직 권한을 확인한 뒤 다시 진행해 주세요." },
82
+ model_unavailable: { action: "settings", message: "요약 모델의 사용 가능 여부와 설정을 확인한 뒤 다시 진행해 주세요." },
83
+ refused: { action: "retry", message: "제공자가 요약을 거절했습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
84
+ timeout: { action: "retry", message: "요약 요청 시간이 초과됐습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
85
+ incomplete: { action: "retry", message: "요약 응답이 완전하지 않습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
86
+ empty: { action: "retry", message: "요약 응답이 비어 있습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
87
+ context_budget: { action: "retry", message: "요약 결과가 대화 모델의 입력 한도에 맞지 않습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
88
+ stale: { action: "retry", message: "요약 중 대화가 바뀌었습니다. 최신 대화에서 다시 진행해 주세요." },
89
+ provider: { action: "retry", message: "원인을 해결한 뒤 다시 진행해 주세요." },
90
+ } as const satisfies Record<CompactionFailureKind, { readonly action: string; readonly message: string }>;
91
+
68
92
  export type RotationReason = "manual" | "age" | ForcedRotationReason;
69
93
  /** Bug 72 proposal 4: limits that rotate a session even with works, updates, decisions or questions open. */
70
94
  export type ForcedRotationReason = "size" | "compactions";
@@ -176,6 +200,9 @@ interface ConvRuntime {
176
200
  finishRotation: (() => void) | null;
177
201
  itemId: string | null;
178
202
  itemText: string;
203
+ /** Set only by the engine's structured start event; never inferred from arrival timing. */
204
+ statsTurnId: string | null;
205
+ statsMessageIndex: number;
179
206
  }
180
207
 
181
208
  interface ContentPart {
@@ -192,7 +219,7 @@ interface SessionEntry {
192
219
  customType?: string;
193
220
  data?: unknown;
194
221
  details?: { notification_id?: string };
195
- message?: { role?: string; content?: ContentPart[] | string; stopReason?: string; clientInputId?: string };
222
+ message?: { role?: string; content?: ContentPart[] | string; stopReason?: string; errorMessage?: string; clientInputId?: string };
196
223
  }
197
224
 
198
225
  interface EntriesResult {
@@ -342,6 +369,23 @@ export class Hub {
342
369
  this.omo = omo;
343
370
  }
344
371
 
372
+ /** Explicit mode activation snapshots every existing main source before admitting new occurrences. */
373
+ async notificationBaseline(): Promise<void> {
374
+ for (const conv of this.store.listConversations()) {
375
+ if (!conv.threadId) continue;
376
+ const sessionPath = conv.threadId;
377
+ const generation = this.generation;
378
+ const revision = this.options.replies?.revision();
379
+ const current = () => generation === this.generation &&
380
+ this.store.getConversation(conv.id)?.threadId === conv.threadId;
381
+ await notificationHistory(conv.threadId, (entry) => {
382
+ if (!current()) throw new Error("reply_baseline_discarded");
383
+ if (revision !== undefined) this.options.replies?.observe({ sessionPath, conv: conv.id, title: conv.title }, [entry], revision);
384
+ }, true);
385
+ if (!current()) throw new Error("reply_baseline_discarded");
386
+ }
387
+ }
388
+
345
389
  attachDecisions(decisions: DecisionService): void {
346
390
  this.decisions = decisions;
347
391
  }
@@ -706,6 +750,8 @@ export class Hub {
706
750
  this.uiSessionScopes.clear();
707
751
  for (const [conv, rt] of this.runtime) {
708
752
  rt.sessionId = null;
753
+ rt.statsTurnId = null;
754
+ rt.statsMessageIndex = 0;
709
755
  rt.opening = null;
710
756
  rt.running = false;
711
757
  rt.waking = null;
@@ -757,6 +803,18 @@ export class Hub {
757
803
  } else if (this.hasUnfinishedTurn(conv.id)) await this.recoverConversation(current);
758
804
  else if (this.openQuestionCards(conv.id, this.conversationScope(conv.id)).size > 0) await this.ensureSession(current);
759
805
  else if (this.store.unreportedWorkUpdates(conv.id).length > 0 || this.workSummary(conv.id).length > 0) await this.ensureSession(current);
806
+ if (this.options.replies?.settings().mode === "all-replies" && current.threadId) {
807
+ if (this.rt(conv.id).sessionId) await this.syncLedger(conv.id);
808
+ else {
809
+ const sessionPath = current.threadId;
810
+ const revision = this.options.replies.revision();
811
+ await notificationHistory(sessionPath, (entry) => {
812
+ if (generation !== this.generation || this.omoStatus !== "ready" ||
813
+ this.store.getConversation(conv.id)?.threadId !== sessionPath) throw new Error("reply_history_discarded");
814
+ this.options.replies?.observe({ sessionPath, conv: conv.id, title: current.title }, [entry], revision);
815
+ });
816
+ }
817
+ }
760
818
  // Opening main comes first. A notification ACK or worker reconciliation cannot hold readiness.
761
819
  void this.notifyWorkUpdates(conv.id);
762
820
  } catch (error) {
@@ -770,12 +828,17 @@ export class Hub {
770
828
  }
771
829
 
772
830
  /** `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> {
831
+ async sendText(convId: string, text: string, src: Source, trust: Trust, images: string[] = [], referenceIds?: unknown, checked = false, submissionId?: string): Promise<void> {
774
832
  if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
833
+ if (submissionId !== undefined && !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(submissionId)) throw new SecurityRuntimeError("held_conflict");
834
+ const context = { ...(submissionId ? { key: JSON.stringify(["message", convId, submissionId]), submissionId } : {}),
835
+ snapshot: structuredClone({ text, src, trust, images, referenceIds }) };
836
+ if (this.options.guard?.existing(context)) return;
775
837
  const conv = this.store.getConversation(convId);
776
838
  if (!conv) throw new Error(`unknown_conversation:${convId}`);
777
839
  const body = text.trim();
778
- const references = resolveMessageReferences(this.store, convId, referenceIds);
840
+ const source = structuredClone(src);
841
+ const references = structuredClone(resolveMessageReferences(this.store, convId, referenceIds));
779
842
  if (this.options.versionReport && images.length === 0 && references.length === 0 && isVersionCommand(body)) {
780
843
  this.emit(convId, "notice", { kind: "version", level: "info", message: await this.options.versionReport() });
781
844
  return;
@@ -792,22 +855,36 @@ export class Hub {
792
855
  throw new MessageReferenceError("message_reference_images_unavailable");
793
856
  }
794
857
  if (!body && attached.length === 0 && references.length === 0) return;
795
- const input = this.store.acceptMainUser(convId, {
796
- text: body, src, trust, ...(attached.length ? { images: attached } : {}),
797
- ...(references.length ? { references } : {}),
798
- });
799
- this.publishStored(input);
800
- this.decisions?.refresh();
801
- const rotationDone = this.rt(convId).rotationDone;
802
- if (rotationDone) await rotationDone;
803
- if (this.closeUncertain(convId)) {
804
- if (this.closeRecoveries.get(convId)?.exhausted) throw new Error("session_close_recovery_exhausted");
805
- this.scheduleCloseRecovery(convId);
806
- this.emit(convId, "notice", { kind: "session-close-recovery", level: "info",
807
- message: "세션 닫기 상태를 확인하고 있습니다. 메시지는 확인 후 전달합니다." });
858
+ const attachmentSnapshot = this.attachments(prompt.images);
859
+ const send = async (receipt?: (ref: SecurityReceipt) => void) => {
860
+ if (receipt && prompt.images.some((name) => !dir || !mediaFile(dir, name))) throw new SecurityRuntimeError("dispatch_failed");
861
+ const input = this.store.acceptMainUser(convId, {
862
+ text: body, src: source, trust, ...(attached.length ? { images: attached } : {}),
863
+ ...(references.length ? { references } : {}),
864
+ }, receipt);
865
+ this.publishStored(input);
866
+ this.decisions?.refresh();
867
+ const rotationDone = this.rt(convId).rotationDone;
868
+ if (rotationDone) await rotationDone;
869
+ if (this.closeUncertain(convId)) {
870
+ if (this.closeRecoveries.get(convId)?.exhausted) {
871
+ if (receipt) throw new SecurityRuntimeError("dispatch_unknown");
872
+ throw new Error("session_close_recovery_exhausted");
873
+ }
874
+ this.scheduleCloseRecovery(convId);
875
+ this.emit(convId, "notice", { kind: "session-close-recovery", level: "info",
876
+ message: "세션 닫기 상태를 확인하고 있습니다. 메시지는 확인 후 전달합니다." });
877
+ return;
878
+ }
879
+ await this.prompt(convId, prompt.text, prompt.images, input.seq, true, attachmentSnapshot, receipt !== undefined);
880
+ };
881
+ // The approved closure owns the inspected references, body and image bytes. Never re-resolve reference ids.
882
+ const found = checked ? null : this.options.guard?.check([prompt.text]);
883
+ if (found) {
884
+ this.options.guard!.hold(convId, "message", found, send, body, context);
808
885
  return;
809
886
  }
810
- await this.prompt(convId, prompt.text, prompt.images, input.seq, true);
887
+ await send();
811
888
  }
812
889
 
813
890
  /** Reserve gateway report admission before its HTTP body is parsed. The source
@@ -1233,6 +1310,8 @@ export class Hub {
1233
1310
  rt.sessionId = null;
1234
1311
  rt.itemId = null;
1235
1312
  rt.itemText = "";
1313
+ rt.statsTurnId = null;
1314
+ rt.statsMessageIndex = 0;
1236
1315
  rt.notified.clear();
1237
1316
  const to = `${this.options.sessionDir.replace(/\/$/, "")}/zgw-${convId}-${fileStamp(this.now())}-${crypto.randomUUID().slice(0, 8)}.jsonl`;
1238
1317
  this.store.rotateThread(convId, to, new Date(this.now()).toISOString(), retirement.handoff);
@@ -1550,7 +1629,7 @@ export class Hub {
1550
1629
  }
1551
1630
 
1552
1631
  /** Sends input once. Native same-session compaction owns overflow recovery, never gateway replay. */
1553
- private async prompt(convId: string, body: string, images: string[] = [], messageSeq?: number, admitted = false): Promise<void> {
1632
+ private async prompt(convId: string, body: string, images: string[] = [], messageSeq?: number, admitted = false, attachmentSnapshot?: ReturnType<Hub["attachments"]>, protectedPayload = false): Promise<void> {
1554
1633
  const conv = this.store.getConversation(convId);
1555
1634
  if (!conv) return;
1556
1635
  const rt = this.rt(convId);
@@ -1568,7 +1647,7 @@ export class Hub {
1568
1647
  if (generation !== this.generation || this.omoStatus !== "ready" || rt.sessionId !== sessionId) return;
1569
1648
  if (admitted && this.store.mainUserDispatched(convId, reference)) return;
1570
1649
  const handoff = nativeCommand ? null : this.store.getSessionMeta(convId).handoff;
1571
- const attached = this.attachments(images);
1650
+ const attached = attachmentSnapshot ?? this.attachments(images);
1572
1651
  const text = [body, attached.note].filter(Boolean).join("\n\n");
1573
1652
  const context = handoff ? `${handoff}\n\nZGW_WORK_STATE ${JSON.stringify(this.workSummary(convId))}` : null;
1574
1653
  const inputId = crypto.randomUUID();
@@ -1598,6 +1677,7 @@ export class Hub {
1598
1677
  this.decisions?.refresh();
1599
1678
  } catch (error) {
1600
1679
  if (generation !== this.generation || this.omoStatus !== "ready") return;
1680
+ if (protectedPayload) throw new SecurityRuntimeError("dispatch_unknown");
1601
1681
  const message = errorMessage(error);
1602
1682
  this.emit(convId, "error", this.continuityError(message));
1603
1683
  }
@@ -1690,6 +1770,28 @@ export class Hub {
1690
1770
  }
1691
1771
  const rt = this.rt(conv);
1692
1772
  switch (event.type) {
1773
+ case "extension_event": {
1774
+ if (event.name === "zgw.turn_started") {
1775
+ rt.statsTurnId = nativeTurnStarted(event.data);
1776
+ rt.statsMessageIndex = 0;
1777
+ } else if (event.name === "zgw.turn_stats" && rt.durableId) {
1778
+ const measured = nativeTurnStats(event.data);
1779
+ if (!measured) return;
1780
+ const stored = this.store.appendTurnStats(conv, rt.durableId, measured);
1781
+ if (stored) this.publishStored(stored);
1782
+ }
1783
+ return;
1784
+ }
1785
+ case "entry_appended": {
1786
+ // Native emits message_end before persistence; this later event closes that gap.
1787
+ const entry = nativeReplyEntry(event.entry);
1788
+ if (entry && this.options.replies) {
1789
+ this.options.replies.observe({ sessionPath: owner.sessionPath, conv, title: this.store.getConversation(conv)?.title ?? "Reply" },
1790
+ [entry], this.options.replies.revision());
1791
+ return;
1792
+ }
1793
+ return this.options.replies ? this.syncLedger(conv) : undefined;
1794
+ }
1693
1795
  case "agent_start":
1694
1796
  rt.run++;
1695
1797
  rt.running = true;
@@ -1717,7 +1819,7 @@ export class Hub {
1717
1819
  }
1718
1820
  case "message_end": {
1719
1821
  const message = event.message as {
1720
- role?: string; content?: ContentPart[] | string; stopReason?: string; errorMessage?: string; clientInputId?: string;
1822
+ role?: string; content?: ContentPart[] | string; stopReason?: string; errorMessage?: string; clientInputId?: string; timestamp?: unknown;
1721
1823
  customType?: string; details?: { notification_id?: string };
1722
1824
  } | undefined;
1723
1825
  if (message?.role === "user") {
@@ -1739,12 +1841,18 @@ export class Hub {
1739
1841
  return;
1740
1842
  }
1741
1843
  if (message?.role !== "assistant") return;
1844
+ rt.statsMessageIndex++;
1742
1845
  const itemId = rt.itemId ?? this.nextId("m");
1743
1846
  const content = typeof message.content === "string" ? [{ type: "text", text: message.content }] : message.content ?? [];
1744
1847
  const thinking = content.filter((c) => c.type === "thinking").map((c) => c.thinking ?? "").join("\n\n").trim();
1745
1848
  if (thinking) this.emit(conv, "thinking.final", { itemId, text: thinking });
1746
1849
  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 });
1850
+ if (text || rt.itemText) this.emit(conv, "message.final", {
1851
+ itemId, text: text || rt.itemText,
1852
+ ...(rt.statsTurnId && rt.durableId && messageTimestamp(message.timestamp) &&
1853
+ message.stopReason !== "aborted" && message.stopReason !== "error" && !message.errorMessage
1854
+ ? { engineTurn: { id: rt.statsTurnId, sessionId: rt.durableId, messageTimestamp: message.timestamp, messageIndex: rt.statsMessageIndex } } : {}),
1855
+ });
1748
1856
  if (message.stopReason === "aborted") {
1749
1857
  this.emit(conv, "notice", { message: "작업을 중단했습니다.", level: "info" });
1750
1858
  } else if (message.stopReason === "error" || message.errorMessage) {
@@ -1752,7 +1860,7 @@ export class Hub {
1752
1860
  }
1753
1861
  rt.itemId = null;
1754
1862
  rt.itemText = "";
1755
- return;
1863
+ return this.options.replies ? this.syncLedger(conv) : undefined;
1756
1864
  }
1757
1865
  case "tool_execution_start":
1758
1866
  this.emit(conv, "progress.update", {
@@ -1776,36 +1884,43 @@ export class Hub {
1776
1884
  });
1777
1885
  return;
1778
1886
  }
1779
- case "compaction_start":
1780
- this.emit(conv, "session.compaction", { status: "running", reason: event.reason, requestId: event.requestId });
1887
+ case "compaction_start": {
1888
+ if (!blockingCompaction(event) || this.compactionSeen(conv, event, false)) return;
1889
+ const requestId = typeof event.requestId === "string" ? event.requestId : undefined;
1890
+ this.emit(conv, "session.compaction", { status: "running", requestId });
1891
+ this.emit(conv, "notice", { kind: "compaction-started", level: "info", requestId,
1892
+ message: "대화 내용을 요약하고 있습니다. 완료될 때까지 기다려 주세요." });
1781
1893
  return;
1894
+ }
1782
1895
  case "compaction_end": {
1783
- const failed = typeof event.errorMessage === "string" || event.accepted === false ||
1784
- (event.accepted !== true && (typeof event.result !== "object" || event.result === null));
1896
+ if (!blockingCompaction(event) || this.compactionSeen(conv, event, true)) return;
1897
+ const requestId = typeof event.requestId === "string" ? event.requestId : undefined;
1898
+ const outcome = compactionOutcome(event);
1785
1899
  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,
1900
+ ...outcome, requestId, accepted: outcome.status === "completed",
1792
1901
  });
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
- });
1902
+ switch (outcome.status) {
1903
+ case "cancelled":
1904
+ this.emit(conv, "notice", { kind: "compaction-cancelled", level: "warning", requestId,
1905
+ message: "대화 요약을 취소했습니다. 이전 내용과 같은 세션을 유지합니다. 이전 입력은 자동으로 다시 보내지 않습니다." });
1906
+ break;
1907
+ case "failed": {
1908
+ const recovery = COMPACTION_RECOVERY[outcome.failureKind];
1909
+ this.emit(conv, "error", { kind: "compaction-failed", requestId, failureKind: outcome.failureKind,
1910
+ action: recovery.action, message: `대화 요약에 실패했습니다. 이전 내용은 유지했습니다. ${recovery.message}` });
1911
+ break;
1912
+ }
1913
+ case "completed":
1914
+ this.emit(conv, "notice", { kind: "compaction-completed", level: "info", requestId,
1915
+ message: "대화 요약을 완료했습니다. 같은 세션에서 계속합니다." });
1916
+ break;
1804
1917
  }
1805
1918
  return;
1806
1919
  }
1807
1920
  case "agent_settled": {
1808
1921
  rt.running = false;
1922
+ rt.statsTurnId = null;
1923
+ rt.statsMessageIndex = 0;
1809
1924
  this.publishActivity(conv);
1810
1925
  this.unwatchConversation(conv);
1811
1926
  this.dialogs.cancelScope(owner.scope, "작업이 끝났습니다.");
@@ -1828,6 +1943,8 @@ export class Hub {
1828
1943
  this.releaseScopedUiOwner(owner);
1829
1944
  rt.sessionId = null;
1830
1945
  rt.durableId = null;
1946
+ rt.statsTurnId = null;
1947
+ rt.statsMessageIndex = 0;
1831
1948
  rt.running = false;
1832
1949
  this.publishActivity(conv);
1833
1950
  rt.run++;
@@ -2230,6 +2347,7 @@ export class Hub {
2230
2347
  }
2231
2348
 
2232
2349
  private async readLedger(conv: string): Promise<void> {
2350
+ const replyRevision = this.options.replies?.revision();
2233
2351
  const rt = this.rt(conv);
2234
2352
  const generation = this.generation;
2235
2353
  const sessionId = rt.sessionId;
@@ -2246,7 +2364,10 @@ export class Hub {
2246
2364
  const result = await this.requireOmo().command<EntriesResult>("get_entries", { sessionId, since });
2247
2365
  logLedgerRead(conv, "since", result);
2248
2366
  if (!current()) throw new LedgerReadDiscarded();
2249
- this.store.appendLedger(conv, { sessionPath, durableId, lastEntryId: since }, ledgerEntries(result.entries), result.leafId);
2367
+ const entries = ledgerEntries(result.entries);
2368
+ // Persist notification identity before advancing the native history cursor.
2369
+ if (replyRevision !== undefined) this.options.replies?.observe({ sessionPath, conv, title: this.store.getConversation(conv)?.title ?? "Reply" }, result.entries, replyRevision);
2370
+ this.store.appendLedger(conv, { sessionPath, durableId, lastEntryId: since }, entries, result.leafId);
2250
2371
  return;
2251
2372
  } catch (error) {
2252
2373
  if (!(error instanceof OmoCommandError) || !error.code.startsWith("Entry not found")) throw error;
@@ -2256,10 +2377,13 @@ export class Hub {
2256
2377
  // A full read is the one unbounded response the ledger asks for: one at a time across conversations.
2257
2378
  const rebuild = this.ledgerRebuilds.then(async () => {
2258
2379
  if (!current()) throw new LedgerReadDiscarded();
2380
+ const rebuildReplyRevision = this.options.replies?.revision();
2259
2381
  const result = await this.requireOmo().command<EntriesResult>("get_entries", { sessionId });
2260
2382
  logLedgerRead(conv, "full", result);
2261
2383
  if (!current()) throw new LedgerReadDiscarded();
2262
- this.store.rebuildLedger(conv, sessionPath, durableId, ledgerEntries(result.entries), result.leafId);
2384
+ const entries = ledgerEntries(result.entries);
2385
+ if (rebuildReplyRevision !== undefined) this.options.replies?.observe({ sessionPath, conv, title: this.store.getConversation(conv)?.title ?? "Reply" }, result.entries, rebuildReplyRevision);
2386
+ this.store.rebuildLedger(conv, sessionPath, durableId, entries, result.leafId);
2263
2387
  });
2264
2388
  this.ledgerRebuilds = rebuild.catch(() => undefined);
2265
2389
  await rebuild;
@@ -2361,6 +2485,7 @@ export class Hub {
2361
2485
  permissionPreset: this.options.permissionPreset,
2362
2486
  retain_on_disconnect: true,
2363
2487
  kind: "interactive",
2488
+ promptSurface: "app",
2364
2489
  context: { zgw_conv: conv.id, zgw_role: "main" },
2365
2490
  });
2366
2491
  return { sessionPath, opened };
@@ -2453,7 +2578,7 @@ export class Hub {
2453
2578
  rt = {
2454
2579
  sessionId: null, opening: null, running: false, run: 0, rotating: false, admitting: 0, clientInputIdentity: false, atomicQueuedResume: false, durableId: null,
2455
2580
  waking: null, wake: null, notified: new Set(),
2456
- itemId: null, itemText: "",
2581
+ itemId: null, itemText: "", statsTurnId: null, statsMessageIndex: 0,
2457
2582
  rotationDone: null, finishRotation: null,
2458
2583
  };
2459
2584
  this.runtime.set(conv, rt);
@@ -2470,6 +2595,17 @@ export class Hub {
2470
2595
  this.emit("main", "notice", { kind: "update", level: "info", message });
2471
2596
  }
2472
2597
 
2598
+ /** Persisted lifecycle records also deduplicate a terminal replay after a gateway restart. */
2599
+ private compactionSeen(conv: string, event: OmoEvent, terminal: boolean): boolean {
2600
+ if (typeof event.requestId !== "string") return false;
2601
+ return this.store.eventsAfter(conv, this.store.lastSeq(conv, "session.rotated")).some((stored) => {
2602
+ if (stored.type !== "session.compaction") return false;
2603
+ const data = stored.data;
2604
+ return typeof data === "object" && data !== null && "requestId" in data && data.requestId === event.requestId &&
2605
+ (!terminal || ("status" in data && data.status !== "running"));
2606
+ });
2607
+ }
2608
+
2473
2609
  private emit(conv: string, type: string, data: unknown, persist = true): StoredEvent | null {
2474
2610
  const stored = persist && conv !== "*" ? this.store.appendEvent(conv, type, data) : null;
2475
2611
  if (stored) this.publishStored(stored);