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/store.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { OMO_CHECK_BASELINE, omoCheckActionable, omoCheckDaytime } from "./omo-check-policy.ts";
3
- import { mkdirSync } from "node:fs";
3
+ import { chmodSync, existsSync, mkdirSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { isDeepStrictEqual } from "node:util";
6
- import type { ConversationInfo, DecisionAnswerRequest } from "./protocol.ts";
6
+ import type { ConversationInfo, DecisionAnswerRequest, NotificationMode, ReplyNotification } from "./protocol.ts";
7
+ import type { NativeTurnStats } from "./turn-stats.ts";
8
+ import { SecurityRuntimeError, securityDecision, type AllowedValue, type HeldState, type SecurityApprover, type SecurityHoldRecord, type SecurityIntent, type SecurityReceipt } from "./security-allow.ts";
7
9
 
8
10
  export interface StoredEvent {
9
11
  seq: number;
@@ -357,7 +359,7 @@ export interface DecisionDismissal {
357
359
  }
358
360
 
359
361
  export class DecisionStoreError extends Error {
360
- constructor(readonly code: "decision_not_found" | "decision_conflict" | "submission_conflict" | "source_conflict" | "invalid_decision" | "invalid_decision_answer" | "invalid_decision_cursor" | "invalid_decision_transition", readonly decision: Decision | null = null) {
362
+ constructor(readonly code: "security_endpoint_required" | "decision_not_found" | "decision_conflict" | "submission_conflict" | "source_conflict" | "invalid_decision" | "invalid_decision_answer" | "invalid_decision_cursor" | "invalid_decision_transition", readonly decision: Decision | null = null) {
361
363
  super(code);
362
364
  }
363
365
  }
@@ -460,9 +462,11 @@ function completeDecisionAnswer(method: DecisionMethod, payload: unknown, respon
460
462
 
461
463
  export class Store {
462
464
  private readonly db: Database;
465
+ private readonly securityReceiptListeners = new Set<() => void>();
463
466
 
464
467
  constructor(dataDir: string) {
465
- mkdirSync(dataDir, { recursive: true });
468
+ mkdirSync(dataDir, { recursive: true, mode: 0o700 });
469
+ chmodSync(dataDir, 0o700);
466
470
  this.db = new Database(join(dataDir, "gateway.sqlite"), { create: true });
467
471
  this.db.exec("PRAGMA journal_mode = WAL;");
468
472
  // Linux builds of bun:sqlite default to synchronous=FULL, which fsyncs every appended event (~80x slower,
@@ -517,6 +521,19 @@ export class Store {
517
521
  acked_at INTEGER
518
522
  );
519
523
  CREATE INDEX IF NOT EXISTS reminders_fire ON reminders(fired_at, fire_at);
524
+ CREATE TABLE IF NOT EXISTS reply_notifications (
525
+ session_path TEXT NOT NULL,
526
+ entry_id TEXT NOT NULL,
527
+ id TEXT NOT NULL UNIQUE,
528
+ data TEXT,
529
+ cancelled INTEGER NOT NULL DEFAULT 0,
530
+ PRIMARY KEY (session_path, entry_id)
531
+ );
532
+ CREATE TABLE IF NOT EXISTS reply_notification_acks (
533
+ id TEXT NOT NULL,
534
+ device_id TEXT NOT NULL,
535
+ PRIMARY KEY (id, device_id)
536
+ );
520
537
  CREATE TABLE IF NOT EXISTS omo_ledger (
521
538
  conv TEXT PRIMARY KEY,
522
539
  session_path TEXT NOT NULL,
@@ -624,6 +641,158 @@ export class Store {
624
641
  this.db.close();
625
642
  throw error;
626
643
  }
644
+ this.db.exec(`
645
+ CREATE TABLE IF NOT EXISTS security_allowed_values (
646
+ id TEXT PRIMARY KEY, value TEXT COLLATE BINARY NOT NULL UNIQUE, data TEXT NOT NULL
647
+ );
648
+ CREATE TABLE IF NOT EXISTS security_allow_revision (
649
+ id INTEGER PRIMARY KEY CHECK(id = 1), revision INTEGER NOT NULL
650
+ );
651
+ INSERT OR IGNORE INTO security_allow_revision VALUES (1, 0);
652
+ CREATE TABLE IF NOT EXISTS security_holds (
653
+ id TEXT PRIMARY KEY, source_key TEXT UNIQUE, intent_submission TEXT UNIQUE, data TEXT NOT NULL
654
+ );
655
+ `);
656
+ for (const suffix of ["", "-wal", "-shm"]) {
657
+ const path = join(dataDir, `gateway.sqlite${suffix}`);
658
+ if (existsSync(path)) chmodSync(path, 0o600);
659
+ }
660
+ }
661
+
662
+ securityAllowedValues(): { revision: number; values: AllowedValue[] } {
663
+ return this.db.transaction(() => ({
664
+ revision: this.db.query<{ revision: number }, []>("SELECT revision FROM security_allow_revision WHERE id = 1").get()!.revision,
665
+ values: this.db.query<{ data: string }, []>("SELECT data FROM security_allowed_values ORDER BY json_extract(data, '$.approvedAt'), id").all()
666
+ .map((row): AllowedValue => JSON.parse(row.data)),
667
+ })).immediate();
668
+ }
669
+
670
+ securityHolds(): SecurityHoldRecord[] {
671
+ return this.db.query<{ data: string }, []>("SELECT data FROM security_holds ORDER BY rowid").all().map((row) => JSON.parse(row.data));
672
+ }
673
+
674
+ securityHold(id: string): SecurityHoldRecord | null {
675
+ const row = this.db.query<{ data: string }, [string]>("SELECT data FROM security_holds WHERE id = ?").get(id);
676
+ return row ? JSON.parse(row.data) : null;
677
+ }
678
+
679
+ securityHoldByKey(key: string): SecurityHoldRecord | null {
680
+ const row = this.db.query<{ data: string }, [string]>("SELECT data FROM security_holds WHERE source_key = ?").get(key);
681
+ return row ? JSON.parse(row.data) : null;
682
+ }
683
+
684
+ createSecurityHold(record: SecurityHoldRecord): void {
685
+ this.db.transaction(() => {
686
+ const s = record.state;
687
+ this.db.query("INSERT INTO security_holds (id, source_key, data) VALUES (?, ?, ?)").run(s.id, record.key, JSON.stringify(record));
688
+ this.insertDecision({
689
+ id: s.id, revision: s.revision, ownerConv: s.conv, scope: `security:${s.conv}`, workId: null,
690
+ sourceSessionPath: null, sourceRequestId: s.id, sourceCommandSeq: null, legacyActionId: null,
691
+ method: "confirm", permission: false, required: false, payload: { kind: "security-check", heldId: s.id },
692
+ bindingState: "closed", deadlineAt: null, status: "pending",
693
+ submissionId: null, submissionRevision: null, submissionMode: null, answer: null, commandId: null,
694
+ answerRoute: null, supersededBy: null, reason: null, createdAt: s.createdAt, updatedAt: s.createdAt,
695
+ });
696
+ }).immediate();
697
+ }
698
+
699
+ private writeSecurityHold(record: SecurityHoldRecord): SecurityHoldRecord {
700
+ const s = record.state;
701
+ this.db.query("UPDATE security_holds SET data = ?, intent_submission = ? WHERE id = ?")
702
+ .run(JSON.stringify(record), record.intent?.submissionId ?? null, s.id);
703
+ const statuses = { pending: "pending", processing: "submitted", sent: "delivered", cancelled: "closed", failed: "needs_review", unconfirmed: "needs_review" } as const;
704
+ this.db.query("UPDATE decisions SET revision = ?, status = ?, reason = ?, updated_at = ? WHERE id = ?")
705
+ .run(s.revision, statuses[s.status], s.errorCode, new Date().toISOString(), s.id);
706
+ return record;
707
+ }
708
+
709
+ claimSecurityHold(id: string, intent: SecurityIntent): { record: SecurityHoldRecord; fresh: boolean } {
710
+ return this.db.transaction(() => {
711
+ const record = this.securityHold(id);
712
+ if (!record) throw new SecurityRuntimeError("held_conflict");
713
+ if (record.intent !== null) {
714
+ if (!isDeepStrictEqual(record.intent, intent)) throw new SecurityRuntimeError("held_conflict", record.state);
715
+ return { record, fresh: false };
716
+ }
717
+ const reused = intent.submissionId === null ? null : this.db.query<{ id: string }, [string]>("SELECT id FROM security_holds WHERE intent_submission = ?").get(intent.submissionId);
718
+ if (reused || record.state.status !== "pending" ||
719
+ (intent.expectedRevision !== null && record.state.revision !== intent.expectedRevision)) throw new SecurityRuntimeError("held_conflict", record.state);
720
+ const claimed = this.writeSecurityHold({ ...record, intent, state: {
721
+ ...record.state, revision: record.state.revision + 1,
722
+ status: intent.send ? "processing" : "cancelled",
723
+ } });
724
+ return { record: claimed, fresh: true };
725
+ }).immediate();
726
+ }
727
+
728
+ reserveSecurityDispatch(id: string, occurrences: readonly { canonicalValue?: string; value: string; kind: string; label: string }[], approver: SecurityApprover): SecurityHoldRecord {
729
+ return this.db.transaction(() => {
730
+ const record = this.securityHold(id);
731
+ if (!record || record.state.status !== "processing" || record.dispatchReserved) throw new SecurityRuntimeError("held_conflict", record?.state);
732
+ const remember = record.intent?.remember === true;
733
+ let revision = this.securityAllowedValues().revision;
734
+ if (remember) {
735
+ let added = 0;
736
+ for (const occurrence of occurrences) {
737
+ const value = occurrence.canonicalValue ?? occurrence.value;
738
+ const entry: AllowedValue = { id: crypto.randomUUID(), value, kind: occurrence.kind, label: occurrence.label,
739
+ approvedAt: new Date().toISOString(), approvedBy: approver, heldId: id };
740
+ added += this.db.query("INSERT OR IGNORE INTO security_allowed_values VALUES (?, ?, ?)").run(entry.id, value, JSON.stringify(entry)).changes;
741
+ }
742
+ if (added > 0) {
743
+ this.db.query("UPDATE security_allow_revision SET revision = revision + 1 WHERE id = 1").run();
744
+ revision++;
745
+ }
746
+ }
747
+ return this.writeSecurityHold({ ...record, dispatchReserved: true, state: {
748
+ ...record.state, revision: record.state.revision + 1, remembered: remember, allowlistRevision: revision,
749
+ } });
750
+ }).immediate();
751
+ }
752
+
753
+ updateSecurityHold(id: string, change: Partial<Pick<HeldState, "status" | "errorCode">>, receipt?: SecurityReceipt, intent?: SecurityIntent): SecurityHoldRecord {
754
+ return this.db.transaction(() => {
755
+ const record = this.securityHold(id);
756
+ if (!record) throw new SecurityRuntimeError("held_conflict");
757
+ return this.writeSecurityHold({ ...record, intent: record.intent ?? intent ?? null, ...(receipt ? { receipt } : {}),
758
+ state: { ...record.state, ...change, revision: record.state.revision + 1 } });
759
+ }).immediate();
760
+ }
761
+
762
+ deleteSecurityAllowedValues(expectedRevision: number, id?: string): { revision: number; removed: number } {
763
+ return this.db.transaction(() => {
764
+ let revision = this.securityAllowedValues().revision;
765
+ if (revision !== expectedRevision) throw new SecurityRuntimeError("allowlist_conflict", undefined, revision);
766
+ const removed = id === undefined ? this.db.query("DELETE FROM security_allowed_values").run().changes
767
+ : this.db.query("DELETE FROM security_allowed_values WHERE id = ?").run(id).changes;
768
+ if (removed > 0) {
769
+ this.db.query("UPDATE security_allow_revision SET revision = revision + 1 WHERE id = 1").run();
770
+ revision++;
771
+ }
772
+ return { revision, removed };
773
+ }).immediate();
774
+ }
775
+
776
+ securityReceipt(ref: SecurityReceipt): "sent" | "unconfirmed" | null {
777
+ if (ref.kind === "answer") {
778
+ const decision = this.getDecision(ref.decisionId);
779
+ if (decision?.submissionId !== ref.submissionId) return null;
780
+ return decision?.status === "delivered" ? "sent" : decision?.reason === "receipt_unknown" ? "unconfirmed" : null;
781
+ }
782
+ const after = this.eventsAfter(ref.conv, ref.messageSeq);
783
+ const inputs = after.filter((event) => event.type === "main.input" && object(event.data) && object(event.data.input_ref) &&
784
+ event.data.input_ref.message_seq === ref.messageSeq);
785
+ return inputs.some((input) => after.some((event) => event.type === "main.input.ack" && object(event.data) &&
786
+ event.data.input_seq === input.seq)) ? "sent" : null;
787
+ }
788
+
789
+ onSecurityReceipt(listener: () => void): () => void {
790
+ this.securityReceiptListeners.add(listener);
791
+ return () => this.securityReceiptListeners.delete(listener);
792
+ }
793
+
794
+ notifySecurityReceipts(): void {
795
+ for (const listener of this.securityReceiptListeners) listener();
627
796
  }
628
797
 
629
798
  /**
@@ -925,6 +1094,7 @@ export class Store {
925
1094
  .run(input.conv, data.session_path, data.handoff);
926
1095
  }
927
1096
  }).immediate();
1097
+ this.notifySecurityReceipts();
928
1098
  }
929
1099
 
930
1100
  eventsAfter(conv: string, seq: number, limit = -1): StoredEvent[] {
@@ -977,11 +1147,36 @@ export class Store {
977
1147
  return { seq: Number(result.lastInsertRowid), conv, type, data, ts };
978
1148
  }
979
1149
 
980
- /** Acceptance and definitely-unsent intent precede every session-admission await. */
981
- acceptMainUser(conv: string, data: unknown): StoredEvent {
1150
+ /** Bind to one persisted native answer; replay never creates another statistics row. */
1151
+ appendTurnStats(conv: string, durableSessionId: string, measured: NativeTurnStats): StoredEvent | null {
1152
+ return this.db.transaction(() => {
1153
+ const existing = this.db.query<{ seq: number }, [string, string]>(
1154
+ "SELECT seq FROM events WHERE conv = ? AND type = 'message.stats' AND json_extract(data, '$.turnId') = ? LIMIT 1",
1155
+ ).get(conv, measured.turnId);
1156
+ if (existing) return null;
1157
+ const answer = this.db.query<{ seq: number; itemId: string }, [string, string, string, number, number]>(
1158
+ `SELECT seq, json_extract(data, '$.itemId') AS itemId FROM events
1159
+ WHERE conv = ? AND type = 'message.final'
1160
+ AND json_extract(data, '$.engineTurn.sessionId') = ?
1161
+ AND json_extract(data, '$.engineTurn.id') = ?
1162
+ AND json_extract(data, '$.engineTurn.messageTimestamp') = ?
1163
+ AND json_extract(data, '$.engineTurn.messageIndex') = ?
1164
+ AND json_type(data, '$.itemId') = 'text'
1165
+ ORDER BY seq DESC LIMIT 1`,
1166
+ ).get(conv, durableSessionId, measured.turnId, measured.finalMessageTimestamp, measured.finalMessageIndex);
1167
+ if (!answer) return null;
1168
+ return this.appendEvent(conv, "message.stats", {
1169
+ itemId: answer.itemId, messageSeq: answer.seq, turnId: measured.turnId, stats: measured.stats,
1170
+ });
1171
+ }).immediate();
1172
+ }
1173
+
1174
+ /** Acceptance, unsent intent and the optional receipt link commit together before session admission. */
1175
+ acceptMainUser(conv: string, data: unknown, receipt?: (ref: SecurityReceipt) => void): StoredEvent {
982
1176
  return this.db.transaction(() => {
983
1177
  const message = this.appendEvent(conv, "message.user", data);
984
1178
  this.appendEvent(conv, "main.user.pending", { message_seq: message.seq });
1179
+ receipt?.({ kind: "message", conv, messageSeq: message.seq });
985
1180
  return message;
986
1181
  }).immediate();
987
1182
  }
@@ -1043,6 +1238,31 @@ export class Store {
1043
1238
  this.db.query("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
1044
1239
  }
1045
1240
 
1241
+ /** A source occurrence is recorded even while disabled, making the eligibility boundary durable. */
1242
+ recordReplyNotification(sessionPath: string, entryId: string, notification: ReplyNotification | null): void {
1243
+ this.db.query("INSERT OR IGNORE INTO reply_notifications (session_path, entry_id, id, data) VALUES (?, ?, ?, ?)")
1244
+ .run(sessionPath, entryId, notification?.id ?? crypto.randomUUID(), notification ? JSON.stringify(notification) : null);
1245
+ }
1246
+
1247
+ pendingReplyNotifications(deviceId: string): ReplyNotification[] {
1248
+ return this.db.query<{ data: string }, [string]>(`SELECT data FROM reply_notifications AS n
1249
+ WHERE data IS NOT NULL AND cancelled = 0 AND NOT EXISTS
1250
+ (SELECT 1 FROM reply_notification_acks AS a WHERE a.id = n.id AND a.device_id = ?) ORDER BY n.rowid`)
1251
+ .all(deviceId).map((row) => JSON.parse(row.data) as ReplyNotification);
1252
+ }
1253
+
1254
+ ackReplyNotification(id: string, deviceId: string): void {
1255
+ this.db.query("INSERT OR IGNORE INTO reply_notification_acks (id, device_id) VALUES (?, ?)").run(id, deviceId);
1256
+ }
1257
+
1258
+ setNotificationMode(mode: NotificationMode): void {
1259
+ this.db.transaction(() => {
1260
+ if (mode === "inbox-only") this.db.query("UPDATE reply_notifications SET cancelled = 1 WHERE data IS NOT NULL").run();
1261
+ this.setSetting("notifications.mode", mode);
1262
+ this.setSetting("notifications.revision", String(Number(this.getSetting("notifications.revision") ?? 0) + 1));
1263
+ }).immediate();
1264
+ }
1265
+
1046
1266
  getMemo(): Memo {
1047
1267
  const row = this.db
1048
1268
  .query<{ text: string; width: number | null; height: number | null; images: string; updated_at: string }, []>(
@@ -1476,6 +1696,13 @@ export class Store {
1476
1696
  }
1477
1697
  if (unreported.length !== updates.length) throw new WorkStoreError("publish_conflict", unreported.map((u) => u.id));
1478
1698
  const event = this.appendEvent(ownerConv, "message.final", { text, work_update_ids: updates.map((update) => update.id) });
1699
+ // Publishing is a distinct complete chat reply, not a replay of the worker's native message.
1700
+ // Admit it in this transaction so a restart between commit and fan-out cannot lose it.
1701
+ this.recordReplyNotification("gateway:work-publication", String(event.seq),
1702
+ this.getSetting("notifications.mode") === "all-replies" && text.trim() ? {
1703
+ id: crypto.randomUUID(), conv: ownerConv,
1704
+ title: this.getConversation(ownerConv)?.title ?? "Reply", text,
1705
+ } : null);
1479
1706
  for (const update of updates) this.db.query("UPDATE work_updates SET reported_event_seq = ? WHERE id = ?").run(event.seq, update.id);
1480
1707
  return [event];
1481
1708
  }).immediate();
@@ -1523,6 +1750,7 @@ export class Store {
1523
1750
  * for sibling A cannot invalidate sibling B's options.
1524
1751
  */
1525
1752
  decisionContextReason(decision: Decision): string | null {
1753
+ if (securityDecision(decision)) return null;
1526
1754
  if (decision.workId !== null) {
1527
1755
  const work = this.getWork(decision.workId);
1528
1756
  if (!work) return "owner_unknown";
@@ -1555,6 +1783,7 @@ export class Store {
1555
1783
  const result = this.db.transaction((): DecisionClaim | DecisionStoreError => {
1556
1784
  let decision = this.getDecision(id);
1557
1785
  if (!decision) throw new DecisionStoreError("decision_not_found");
1786
+ if (securityDecision(decision)) throw new DecisionStoreError("security_endpoint_required");
1558
1787
  if (!Number.isSafeInteger(request.expectedRevision) || request.expectedRevision < 1 ||
1559
1788
  !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(request.submissionId)) {
1560
1789
  throw new DecisionStoreError("invalid_decision_answer", decision);
@@ -2205,6 +2434,7 @@ export class Store {
2205
2434
  return this.db.transaction((): DecisionDismissal => {
2206
2435
  const d = this.getDecision(id);
2207
2436
  if (!d) throw new DecisionStoreError("decision_not_found");
2437
+ if (securityDecision(d)) throw new DecisionStoreError("security_endpoint_required");
2208
2438
  if (d.revision !== expectedRevision || d.status === "delivered" || d.status === "closed" || d.status === "submitted") {
2209
2439
  throw new DecisionStoreError("decision_conflict", d);
2210
2440
  }
@@ -0,0 +1,49 @@
1
+ import type { TurnStats } from "./protocol.ts";
2
+
3
+ export interface NativeTurnStats {
4
+ readonly version: 1;
5
+ readonly turnId: string;
6
+ readonly finalMessageTimestamp: number;
7
+ readonly finalMessageIndex: number;
8
+ readonly stats: TurnStats;
9
+ }
10
+
11
+ function record(value: unknown): value is Record<string, unknown> {
12
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13
+ }
14
+
15
+ export function turnId(value: unknown): string | null {
16
+ return typeof value === "string" && /^[a-zA-Z0-9_-]{1,128}$/.test(value) ? value : null;
17
+ }
18
+
19
+ export function messageTimestamp(value: unknown): value is number {
20
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
21
+ }
22
+
23
+ /** Parse the engine boundary once. Unknown fields never reach stored/client data. */
24
+ export function nativeTurnStats(value: unknown): NativeTurnStats | null {
25
+ if (!record(value) || value.version !== 1) return null;
26
+ const id = turnId(value.turnId);
27
+ if (!id || !messageTimestamp(value.finalMessageTimestamp) || !record(value.stats)) return null;
28
+ if (typeof value.finalMessageIndex !== "number" || !Number.isSafeInteger(value.finalMessageIndex) || value.finalMessageIndex <= 0) return null;
29
+ const { tokensPerSecond, modelDurationMs, cacheHitPercent } = value.stats;
30
+ if (tokensPerSecond !== undefined &&
31
+ (typeof tokensPerSecond !== "number" || !Number.isFinite(tokensPerSecond) || tokensPerSecond <= 0)) return null;
32
+ if (modelDurationMs !== undefined &&
33
+ (typeof modelDurationMs !== "number" || !Number.isFinite(modelDurationMs) || modelDurationMs <= 0)) return null;
34
+ if (cacheHitPercent !== undefined &&
35
+ (typeof cacheHitPercent !== "number" || !Number.isFinite(cacheHitPercent) || cacheHitPercent < 0 || cacheHitPercent > 100)) return null;
36
+ if (tokensPerSecond === undefined && modelDurationMs === undefined && cacheHitPercent === undefined) return null;
37
+ return {
38
+ version: 1, turnId: id, finalMessageTimestamp: value.finalMessageTimestamp, finalMessageIndex: value.finalMessageIndex,
39
+ stats: {
40
+ ...(tokensPerSecond === undefined ? {} : { tokensPerSecond }),
41
+ ...(modelDurationMs === undefined ? {} : { modelDurationMs }),
42
+ ...(cacheHitPercent === undefined ? {} : { cacheHitPercent }),
43
+ },
44
+ };
45
+ }
46
+
47
+ export function nativeTurnStarted(value: unknown): string | null {
48
+ return record(value) && value.version === 1 ? turnId(value.turnId) : null;
49
+ }
package/src/work-watch.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { OmoEvent } from "./omo/client.ts";
1
+ import { blockingCompaction, COMPACTION_FAILURE_KINDS, compactionOutcome, type OmoEvent } from "./omo/client.ts";
2
2
  import type { Store, WorkCommand, WorkItem, WorkUpdate } from "./store.ts";
3
3
 
4
4
  /**
@@ -68,7 +68,7 @@ export interface Verdict {
68
68
  readonly evidence?: Readonly<Record<string, unknown>>;
69
69
  }
70
70
 
71
- /** Live signals of one work session, kept in memory only and reset whenever the OMO connection changes. */
71
+ /** Live runtime signals reset on connection changes; safe compaction outcomes reload from work observations. */
72
72
  export interface Live {
73
73
  observedSince: number;
74
74
  lastActivityAt: number;
@@ -81,11 +81,12 @@ export interface Live {
81
81
  readonly openTools: Map<string, { name: string; at: number }>;
82
82
  /** Running sub-tasks from senpi-task's `omo.task.updated`; null while unknown. */
83
83
  children: { active: number; at: number } | null;
84
+ readonly compactionTerminals: Set<string>;
84
85
  lastError: { kind: "model_error" | "compaction_failed" | "retry_exhausted"; message: string; at: number } | null;
85
86
  }
86
87
 
87
88
  export function newLive(now: number): Live {
88
- return { observedSince: now, lastActivityAt: now, streaming: null, runStartedAt: null, runObserved: false, stoppedAt: null, openTools: new Map(), children: null, lastError: null };
89
+ return { observedSince: now, lastActivityAt: now, streaming: null, runStartedAt: null, runObserved: false, stoppedAt: null, openTools: new Map(), children: null, compactionTerminals: new Set(), lastError: null };
89
90
  }
90
91
 
91
92
  export interface JudgeInput {
@@ -270,6 +271,15 @@ export class WorkWatcher {
270
271
  let live = this.lives.get(workId);
271
272
  if (!live) {
272
273
  live = newLive(this.clock());
274
+ for (const update of this.store.listWorkUpdates(workId)) {
275
+ const details = update.details;
276
+ if (update.kind !== "observation" || !record(details) || details.source !== "compaction-watcher" ||
277
+ typeof details.at !== "number" || !Number.isFinite(new Date(details.at).getTime())) continue;
278
+ if (typeof details.requestId === "string") live.compactionTerminals.add(details.requestId);
279
+ const failureKind = COMPACTION_FAILURE_KINDS.find((kind) => kind === details.failureKind);
280
+ if (failureKind) live.lastError = { kind: "compaction_failed", message: failureKind, at: details.at };
281
+ else if (details.failureKind === null) live.lastError = null;
282
+ }
273
283
  this.lives.set(workId, live);
274
284
  }
275
285
  return live;
@@ -306,8 +316,22 @@ export class WorkWatcher {
306
316
  const now = this.clock();
307
317
  const live = this.live(workId);
308
318
  live.lastActivityAt = now;
319
+ const previousError = live.lastError;
320
+ const changed = this.apply(live, event, now);
321
+ if (previousError !== live.lastError &&
322
+ (previousError?.kind === "compaction_failed" || live.lastError?.kind === "compaction_failed")) {
323
+ this.store.recordWorkUpdate(workId, {
324
+ sourceKey: `watch:compaction:${crypto.randomUUID()}`,
325
+ kind: "observation",
326
+ commandSeq: null,
327
+ summary: "compaction_state",
328
+ details: { source: "compaction-watcher", at: now,
329
+ failureKind: live.lastError?.kind === "compaction_failed" ? live.lastError.message : null,
330
+ requestId: event.type === "compaction_end" && typeof event.requestId === "string" ? event.requestId : null },
331
+ });
332
+ }
309
333
  // Plain activity changes a verdict only when it clears an unresponsive or broken one.
310
- if (this.apply(live, event, now) || this.flagged(workId)) this.schedule();
334
+ if (changed || this.flagged(workId)) this.schedule();
311
335
  }
312
336
 
313
337
  private flagged(workId: string): boolean {
@@ -351,12 +375,14 @@ export class WorkWatcher {
351
375
  return true;
352
376
  }
353
377
  case "compaction_end": {
354
- if (event.aborted === true) return false;
355
- const failed = typeof event.errorMessage === "string" || event.accepted === false ||
356
- (event.accepted !== true && !record(event.result));
357
- if (!failed) return false;
358
- const cause = typeof event.errorMessage === "string" ? event.errorMessage : typeof event.rejectionCause === "string" ? event.rejectionCause : "완료 결과가 없습니다";
359
- live.lastError = { kind: "compaction_failed", message: cause, at: now };
378
+ if (!blockingCompaction(event)) return false;
379
+ if (typeof event.requestId === "string") {
380
+ if (live.compactionTerminals.has(event.requestId)) return false;
381
+ live.compactionTerminals.add(event.requestId);
382
+ }
383
+ const outcome = compactionOutcome(event);
384
+ if (outcome.status !== "failed") return false;
385
+ live.lastError = { kind: "compaction_failed", message: outcome.failureKind, at: now };
360
386
  return true;
361
387
  }
362
388
  case "auto_retry_end":
package/src/work.ts CHANGED
@@ -3,6 +3,8 @@ import { isAbsolute, join } from "node:path";
3
3
  import { isDeepStrictEqual } from "node:util";
4
4
  import { OmoCommandError, type OmoClient, type OmoEvent, type OmoStatus } from "./omo/client.ts";
5
5
  import { DecisionStoreError, Store, WorkStoreError, type LedgerState, type MainInput, type StoredEvent, type WorkCommand, type WorkItem, type WorkReport, type WorkUpdateKind } from "./store.ts";
6
+ import { nativeReplyEntry, type NativeReplyEntry, type NativeReplyObserver } from "./reply-notifications.ts";
7
+ import { notificationHistory } from "./notification-history.ts";
6
8
 
7
9
  const ACK_TIMEOUT_MS = 30_000;
8
10
  const PREFIX = "ZGW_WORK_COMMAND ";
@@ -15,7 +17,7 @@ type State = {
15
17
  ordered?: { text: string; mode?: string; clientInputId?: string }[];
16
18
  pendingQuestions?: OmoEvent[]; atomicQueuedResume?: boolean; queueEpoch?: string;
17
19
  };
18
- type Entry = { id: string; type: string; customType?: string; data?: unknown; message?: { role: string; content: unknown } };
20
+ type Entry = NativeReplyEntry & { id: string; type: string; customType?: string; data?: unknown };
19
21
  export interface WorkSessionOwner {
20
22
  readonly conv: string;
21
23
  readonly scope: `work:${string}`;
@@ -30,6 +32,7 @@ export interface WorkRequest {
30
32
  payload: RecordValue;
31
33
  }
32
34
  export interface WorkRouterOptions {
35
+ replies?: NativeReplyObserver;
33
36
  sessionDir: string;
34
37
  permissionPreset: string;
35
38
  /** Invoked only for newly committed events. Task 6 also uses this boundary for report-turn metadata. */
@@ -121,6 +124,20 @@ export class WorkRouter {
121
124
  }
122
125
 
123
126
  private get ready(): boolean { return !this.stopped && this.status === "ready" && this.omo.currentStatus === "ready"; }
127
+ /** Includes finished, normally lazy sources so enabling never backfills their old replies. */
128
+ async notificationBaseline(): Promise<void> {
129
+ for (const work of this.allWorks()) {
130
+ const generation = this.generation;
131
+ const revision = this.options.replies?.revision();
132
+ const current = () => !this.stopped && generation === this.generation &&
133
+ this.store.getWork(work.id)?.sessionPath === work.sessionPath;
134
+ await notificationHistory(work.sessionPath, (entry) => {
135
+ if (!current()) throw new Error("reply_baseline_discarded");
136
+ if (revision !== undefined) this.options.replies?.observe({ sessionPath: work.sessionPath, conv: work.ownerConv, workId: work.id, title: work.title }, [entry], revision);
137
+ }, true);
138
+ if (!current()) throw new Error("reply_baseline_discarded");
139
+ }
140
+ }
124
141
  private allWorks(): WorkItem[] { return this.store.listConversations().flatMap((conv) => this.store.listWorks(conv.id)); }
125
142
  private work(id: string): WorkItem { const work = this.store.getWork(id); if (!work) throw new RoutingError("work_not_found", 404); return work; }
126
143
  private current(workId: string, binding: Binding): boolean { return this.ready && binding.generation === this.generation && this.bindings.get(workId) === binding; }
@@ -463,7 +480,7 @@ export class WorkRouter {
463
480
  const saved = this.store.getSetting("omo.model");
464
481
  const model: { provider: string; modelId: string } | null = saved ? JSON.parse(saved) : null;
465
482
  const promise = this.omo.command<{ sessionId: string; attached?: boolean; state?: State }>("open_session", {
466
- sessionPath: work.sessionPath, cwd: work.cwd, permissionPreset: this.options.permissionPreset, kind: "worker", retain_on_disconnect: true, auto_title: false,
483
+ sessionPath: work.sessionPath, cwd: work.cwd, permissionPreset: this.options.permissionPreset, kind: "worker", promptSurface: "app", retain_on_disconnect: true, auto_title: false,
467
484
  // Native applies these only on first creation, never on a persisted resume.
468
485
  ...(model ? { provider: model.provider, modelId: model.modelId } : {}),
469
486
  context: { zgw_role: "work", zgw_work: work.id, zgw_conv: work.ownerConv },
@@ -580,11 +597,16 @@ export class WorkRouter {
580
597
  private async readEvidence(workId: string): Promise<void> {
581
598
  const work = this.work(workId);
582
599
  const binding = await this.ensure(work);
600
+ let replyRevision = this.options.replies?.revision();
583
601
  const [entries, state] = await Promise.all([
584
- this.entriesAfter(binding.sessionId, this.store.workEntryCursor(workId), () => this.current(workId, binding)),
602
+ this.entriesAfter(binding.sessionId, this.store.workEntryCursor(workId), () => {
603
+ replyRevision = this.options.replies?.revision();
604
+ return this.current(workId, binding);
605
+ }),
585
606
  this.omo.command<State>("get_state", { sessionId: binding.sessionId }),
586
607
  ]);
587
608
  if (!entries || !this.current(workId, binding)) return;
609
+ if (replyRevision !== undefined) this.options.replies?.observe({ sessionPath: work.sessionPath, conv: work.ownerConv, workId, title: work.title }, entries, replyRevision);
588
610
  if (typeof state.isStreaming === "boolean") this.options.onRuntime?.(workId, state.isStreaming);
589
611
  for (const entry of entries) {
590
612
  if (entry.type === "custom" && (entry.customType === "zgw.native_permission_asked" || entry.customType === "zgw.native_permission_replied")) {
@@ -644,6 +666,12 @@ export class WorkRouter {
644
666
  const [workId, binding] = found;
645
667
  if (!this.current(workId, binding)) return false;
646
668
  this.options.onWorkEvent?.(workId, event);
669
+ if (this.options.replies && event.type === "entry_appended") {
670
+ const entry = nativeReplyEntry(event.entry);
671
+ const work = this.work(workId);
672
+ if (entry) this.options.replies.observe({ sessionPath: work.sessionPath, conv: work.ownerConv, workId, title: work.title },
673
+ [entry], this.options.replies.revision());
674
+ }
647
675
  if (event.type === "extension_ui_request" && event.method === "notify" && this.store.isOmoCheckWork(workId)) {
648
676
  // Preserve attribution before Hub turns the frame into an unattributed main-chat notice.
649
677
  const sourceKey = `omo-check-notice:${typeof event.id === "string" ? event.id : crypto.randomUUID()}`;
@@ -725,10 +753,21 @@ export class WorkRouter {
725
753
  // the native per-connection queue and the bridge frontend budget, dropping this connection.
726
754
  for (const work of works) {
727
755
  if (!this.ready || generation !== this.generation) return;
728
- // Finished sessions are opened on demand by owner(), reconcile() or dispatch().
729
- if (["completed", "failed", "cancelled"].includes(work.status) &&
730
- !this.store.listWorkCommands(work.id).some((command) => ["pending", "sending", "unknown"].includes(command.delivery))) continue;
756
+ // A completed report may precede the final assistant reply. Enabled notifications
757
+ // must recover that tail even when command delivery already reached its terminal state.
731
758
  try {
759
+ if (["completed", "failed", "cancelled"].includes(work.status) &&
760
+ !this.store.listWorkCommands(work.id).some((command) => ["pending", "sending", "unknown"].includes(command.delivery))) {
761
+ if (this.options.replies?.settings().mode === "all-replies") {
762
+ const revision = this.options.replies.revision();
763
+ await notificationHistory(work.sessionPath, (entry) => {
764
+ if (!this.ready || generation !== this.generation ||
765
+ this.store.getWork(work.id)?.sessionPath !== work.sessionPath) throw new Error("reply_history_discarded");
766
+ this.options.replies?.observe({ sessionPath: work.sessionPath, conv: work.ownerConv, workId: work.id, title: work.title }, [entry], revision);
767
+ });
768
+ }
769
+ continue;
770
+ }
732
771
  await this.reconcile(work.id);
733
772
  this.schedule(work.id);
734
773
  } catch (error) { this.fail(error); }
package/web/DESIGN.md CHANGED
@@ -14,6 +14,10 @@ The chat retains its bounded scroll area. The memo remains an anchored,
14
14
  resizable panel inside `messages-wrap`, constrained to the available width
15
15
  and height. Text owns the remaining panel height; image controls do not
16
16
  overlap the composer or resize handle.
17
+ The message viewport reserves a 64px right gutter outside its scroll area.
18
+ The existing 34px navigation buttons and their 20px right offset occupy this
19
+ gutter, with 10px clearance. All message kinds share this boundary, including
20
+ system notices. The message list remains the only vertical scroll owner.
17
21
 
18
22
  ## 4. Typography and spacing
19
23
  Keep existing 12px panel labels, 14px memo text, 8/12px control spacing,
@@ -105,6 +109,16 @@ image-specific remove labels. Keyboard focus remains visible. Closing or
105
109
  reopening the memo must not lose saved text or image references.
106
110
 
107
111
  ## 8. Verification and retained debt
112
+ Security confirmation reuses the same inbox item, text buttons and inline delete
113
+ confirmation. Chat and inbox share the held identity. Pending, processing, sent,
114
+ cancelled, failed and unconfirmed states have text labels; only confirmed sent
115
+ uses `--ok`. Canonical values preserve whitespace and wrap at narrow widths.
116
+ Allowed values remain inside the existing security settings section. The list
117
+ uses 12px metadata, 8px spacing, 4px radii and existing border/muted tokens.
118
+ Loading, offline, empty, error, retry and busy states use live status regions.
119
+ Delete confirmation focuses the keep action and explains future-check effects.
120
+ Real desktop/mobile and light/dark QA is assigned to the integration lead.
121
+
108
122
  Exercise empty/one/two/full/uploading/error/remove/reopen/remote-update states
109
123
  at narrow and wide sizes. Preserve the existing theme and unrelated controls.
110
124
  Existing broad application-script size is outside this feature; extract only
@@ -139,3 +153,28 @@ A sent message shows the references it carried above its text as quotes: the 1px
139
153
  as the title; reference text is set as DOM text only, never HTML. No new tokens or motion; autocomplete, IME, autocopy,
140
154
  마지막 보냄 includes the reference count, including a reference-only send. ArrowUp still recalls typed text only,
141
155
  as it already does for image-only messages.
156
+
157
+ ## 10. Inline chat diagrams (180)
158
+ Only complete top-level, unindented triple-backtick `zigmini-diagram` fences
159
+ become diagrams. Invalid and unfinished fences retain the existing source view.
160
+ The figure uses `--bg-card`, `--bg-input`, `--border`, `--text` and `--muted`.
161
+ Native node text inherits the message font and stays selectable. Nodes use
162
+ 8px/12px padding, a 4px radius and a 1px border; the figure uses an 8px radius.
163
+ Top-to-bottom levels use intrinsic max-content columns, 24px column gaps and
164
+ 48px row gaps. A 24px right gutter carries edges that skip a level; their
165
+ horizontal segments stay in the row gaps, never through intermediate labels.
166
+ Labels never scale down or truncate, including at enlarged text.
167
+ The diagram viewport alone owns horizontal and vertical diagram scrolling,
168
+ bounded to the message width and 24rem height. The transcript retains normal
169
+ scroll chaining. At phone widths up to 640px, the figure leaves 48px on its right
170
+ for the existing transcript navigation buttons, keeping its source and scroll
171
+ controls clear without changing those buttons or other message layouts.
172
+ The viewport is a named keyboard-focusable region with a visible
173
+ text-colour focus outline. A native details/summary source foldout follows it,
174
+ with a 44px minimum summary target and selectable complete fence source.
175
+ The title and a native text list describe every directed edge for assistive
176
+ technology; SVG connectors are inert and hidden from it. Disconnected nodes
177
+ remain visible. No animation, network, remote fonts, new settings or exports.
178
+ Source foldouts retain their local open state across streaming updates.
179
+ Desktop/phone light/dark, enlarged text, source/copy and reference QA belong
180
+ to the parent integration lane; local unit checks do not claim visual approval.