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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.ko.md +27 -5
  2. package/README.md +27 -5
  3. package/assets/agent/gateway-request-rules.md +3 -1
  4. package/assets/android/zigmini.apk +0 -0
  5. package/assets/android/zigmini.apk.json +3 -3
  6. package/assets/build.json +1 -1
  7. package/docs/manual.ko.md +57 -4
  8. package/docs/manual.md +55 -4
  9. package/host/omo-bridge/ascii-json.ts +5 -0
  10. package/host/omo-bridge/bridge.ts +50 -44
  11. package/host/omo-check/patches/omo-5.1.44-compaction-port.patch +2630 -0
  12. package/host/omo-check/patches/omo-5.1.44-gateway-port.manifest.json +329 -0
  13. package/host/omo-check/patches/omo-5.1.44-provider-port.patch +440 -0
  14. package/host/omo-check/patches/omo-5.1.44-rpc-port.patch +778 -0
  15. package/host/omo-check/patches/omo-5.1.48-compaction-port.patch +2629 -0
  16. package/host/omo-check/patches/omo-5.1.48-gateway-port.manifest.json +355 -0
  17. package/host/omo-check/patches/omo-5.1.48-provider-port.patch +440 -0
  18. package/host/omo-check/patches/omo-5.1.48-rpc-port.patch +799 -0
  19. package/host/omo-check/patches/omo-5.1.48-runtime-compat.patch +31 -0
  20. package/package.json +1 -1
  21. package/src/cli/setup.ts +6 -2
  22. package/src/decisions.ts +32 -13
  23. package/src/engine/compat.json +4 -4
  24. package/src/engine/install.ts +68 -20
  25. package/src/engine/rpc-socket.ts +36 -0
  26. package/src/engine/schema-guard.ts +34 -2
  27. package/src/hub.ts +57 -31
  28. package/src/main.ts +22 -7
  29. package/src/notification-history.ts +56 -0
  30. package/src/omo/client.ts +16 -3
  31. package/src/omo/frame-socket.ts +330 -0
  32. package/src/protocol.ts +2 -1
  33. package/src/security-allow.ts +126 -0
  34. package/src/security-check.ts +355 -37
  35. package/src/store.ts +165 -5
  36. package/src/update/handoff-system.ts +22 -0
  37. package/src/update/handoff.ts +10 -2
  38. package/src/update/helper.ts +11 -1
  39. package/src/update/request.ts +15 -2
  40. package/src/work.ts +21 -7
  41. package/web/DESIGN.md +39 -0
  42. package/web/app.js +198 -69
  43. package/web/decisions.d.ts +1 -1
  44. package/web/decisions.js +3 -0
  45. package/web/diagram.d.ts +28 -0
  46. package/web/diagram.js +228 -0
  47. package/web/index.html +13 -0
  48. package/web/security-inbox.d.ts +35 -0
  49. package/web/security-inbox.js +237 -0
  50. package/web/style.css +31 -1
package/src/store.ts CHANGED
@@ -1,10 +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
6
  import type { ConversationInfo, DecisionAnswerRequest, NotificationMode, ReplyNotification } from "./protocol.ts";
7
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";
8
9
 
9
10
  export interface StoredEvent {
10
11
  seq: number;
@@ -358,7 +359,7 @@ export interface DecisionDismissal {
358
359
  }
359
360
 
360
361
  export class DecisionStoreError extends Error {
361
- 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) {
362
363
  super(code);
363
364
  }
364
365
  }
@@ -461,9 +462,11 @@ function completeDecisionAnswer(method: DecisionMethod, payload: unknown, respon
461
462
 
462
463
  export class Store {
463
464
  private readonly db: Database;
465
+ private readonly securityReceiptListeners = new Set<() => void>();
464
466
 
465
467
  constructor(dataDir: string) {
466
- mkdirSync(dataDir, { recursive: true });
468
+ mkdirSync(dataDir, { recursive: true, mode: 0o700 });
469
+ chmodSync(dataDir, 0o700);
467
470
  this.db = new Database(join(dataDir, "gateway.sqlite"), { create: true });
468
471
  this.db.exec("PRAGMA journal_mode = WAL;");
469
472
  // Linux builds of bun:sqlite default to synchronous=FULL, which fsyncs every appended event (~80x slower,
@@ -638,6 +641,158 @@ export class Store {
638
641
  this.db.close();
639
642
  throw error;
640
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();
641
796
  }
642
797
 
643
798
  /**
@@ -939,6 +1094,7 @@ export class Store {
939
1094
  .run(input.conv, data.session_path, data.handoff);
940
1095
  }
941
1096
  }).immediate();
1097
+ this.notifySecurityReceipts();
942
1098
  }
943
1099
 
944
1100
  eventsAfter(conv: string, seq: number, limit = -1): StoredEvent[] {
@@ -1015,11 +1171,12 @@ export class Store {
1015
1171
  }).immediate();
1016
1172
  }
1017
1173
 
1018
- /** Acceptance and definitely-unsent intent precede every session-admission await. */
1019
- acceptMainUser(conv: string, data: unknown): StoredEvent {
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 {
1020
1176
  return this.db.transaction(() => {
1021
1177
  const message = this.appendEvent(conv, "message.user", data);
1022
1178
  this.appendEvent(conv, "main.user.pending", { message_seq: message.seq });
1179
+ receipt?.({ kind: "message", conv, messageSeq: message.seq });
1023
1180
  return message;
1024
1181
  }).immediate();
1025
1182
  }
@@ -1593,6 +1750,7 @@ export class Store {
1593
1750
  * for sibling A cannot invalidate sibling B's options.
1594
1751
  */
1595
1752
  decisionContextReason(decision: Decision): string | null {
1753
+ if (securityDecision(decision)) return null;
1596
1754
  if (decision.workId !== null) {
1597
1755
  const work = this.getWork(decision.workId);
1598
1756
  if (!work) return "owner_unknown";
@@ -1625,6 +1783,7 @@ export class Store {
1625
1783
  const result = this.db.transaction((): DecisionClaim | DecisionStoreError => {
1626
1784
  let decision = this.getDecision(id);
1627
1785
  if (!decision) throw new DecisionStoreError("decision_not_found");
1786
+ if (securityDecision(decision)) throw new DecisionStoreError("security_endpoint_required");
1628
1787
  if (!Number.isSafeInteger(request.expectedRevision) || request.expectedRevision < 1 ||
1629
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)) {
1630
1789
  throw new DecisionStoreError("invalid_decision_answer", decision);
@@ -2275,6 +2434,7 @@ export class Store {
2275
2434
  return this.db.transaction((): DecisionDismissal => {
2276
2435
  const d = this.getDecision(id);
2277
2436
  if (!d) throw new DecisionStoreError("decision_not_found");
2437
+ if (securityDecision(d)) throw new DecisionStoreError("security_endpoint_required");
2278
2438
  if (d.revision !== expectedRevision || d.status === "delivered" || d.status === "closed" || d.status === "submitted") {
2279
2439
  throw new DecisionStoreError("decision_conflict", d);
2280
2440
  }
@@ -10,6 +10,9 @@ import { type BridgeGeneration, type HandoffDeps, type OverlapControl, runHandof
10
10
  import { installedVersion } from "./helper.ts";
11
11
  import { initialOverlap, observe, type OverlapState } from "../runtime/overlap-policy.ts";
12
12
  import { sampleResources } from "../runtime/resource-sample.ts";
13
+ import { agentDir } from "../model-accounts.ts";
14
+ import { attemptRefusal, readGuardState, readStoreSchema, storeIncompatibility } from "../engine/schema-guard.ts";
15
+ import { supportedSchema } from "../engine/thread-schema.ts";
13
16
 
14
17
  const OVERLAP_REASONS: Readonly<Record<string, string>> = {
15
18
  critical_pressure: "메모리 압박 단계가 위험입니다",
@@ -110,6 +113,25 @@ export function systemHandoffDeps(launchd: LaunchdDeps, ctx: LaunchdContext, opt
110
113
  writeRegistry: (registry: SlotRegistry | null) => registry === null ? rmSync(paths.slotsFile, { force: true }) : writeRegistry(paths.slotsFile, registry),
111
114
  snapshot: () => createSnapshot(ctx.packageRoot, paths.generations, version),
112
115
  engineFolder: () => existsSync(paths.omoCurrent) ? basename(realpathSync(paths.omoCurrent)) : null,
116
+ // Same agent folder rule as the bridge's own guard (process env overlaid with config.env) and its pinned engine folder.
117
+ preflight: (record) => {
118
+ let supported: number | null = null;
119
+ try {
120
+ supported = supportedSchema(record.engine === null ? paths.omoCurrent : join(paths.omo, record.engine));
121
+ } catch {
122
+ supported = null;
123
+ }
124
+ let config: Record<string, string>;
125
+ try {
126
+ config = readConfig(launchd, ctx);
127
+ } catch {
128
+ // A broken config.env is reported by startBridge, which reads it next and fails the attempt untouched.
129
+ return null;
130
+ }
131
+ const found = storeIncompatibility(readStoreSchema(agentDir({ ...ctx.env, ...config })), supported);
132
+ return found === null ? null : `저장소 스키마 ${found.store} > 엔진 지원 ${found.supported}`;
133
+ },
134
+ startRefusal: (record) => attemptRefusal(readGuardState(paths.run), { slot: record.slot, engine: record.engine, since: record.since }),
113
135
  startBridge: async (record) => {
114
136
  let config: Record<string, string>;
115
137
  try {
@@ -34,6 +34,10 @@ export interface HandoffDeps {
34
34
  readonly writeRegistry: (registry: SlotRegistry | null) => void;
35
35
  readonly snapshot: () => { readonly name: string; readonly dir: string };
36
36
  readonly engineFolder: () => string | null;
37
+ /** Findings 214-217: why the new slot's engine cannot use the shared store, checked before it starts; null when it can. */
38
+ readonly preflight: (record: SlotRecord) => string | null;
39
+ /** The startup refusal of this attempt (this slot, engine and start time) only; a stale shared record is null. */
40
+ readonly startRefusal: (record: SlotRecord) => string | null;
37
41
  readonly startBridge: (record: SlotRecord) => Promise<string | null>;
38
42
  readonly stopBridge: (slot: RuntimeSlot) => Promise<void>;
39
43
  readonly generation: (record: SlotRecord, action: "status" | "drain" | "terminate") => Promise<BridgeGeneration | null>;
@@ -111,14 +115,18 @@ export async function runHandoff(deps: HandoffDeps): Promise<HandoffResult> {
111
115
  await deps.stopBridge(slot);
112
116
  deps.writeRegistry(stored);
113
117
  };
118
+ const blocked = deps.preflight(fresh);
119
+ if (blocked !== null) return fail(true, `새 세대(슬롯 ${slot}, 엔진 ${fresh.engine ?? "current"})를 시작하지 않았습니다: ${blocked}`);
114
120
  deps.writeRegistry({ schema: 1, slots: [old, fresh] });
115
121
  const started = await deps.startBridge(fresh);
116
122
  if (started !== null) {
117
123
  await undo();
118
124
  return fail(true, `새 세대(슬롯 ${slot})를 시작하지 못했습니다: ${started}`);
119
125
  }
120
- const up = await until(deps, BRIDGE_START_WAIT_MS, async () => (await deps.generation(fresh, "status"))?.omo === "running");
121
- const unhealthy = up ? await deps.healthCheck(fresh) : NOT_UP;
126
+ let refused: string | null = null;
127
+ const up = await until(deps, BRIDGE_START_WAIT_MS, async () => (refused = deps.startRefusal(fresh)) !== null ||
128
+ (await deps.generation(fresh, "status"))?.omo === "running");
129
+ const unhealthy = refused ?? (up ? await deps.healthCheck(fresh) : NOT_UP);
122
130
  if (unhealthy !== null) {
123
131
  await undo();
124
132
  return fail(true, `새 세대(슬롯 ${slot}) 확인 실패: ${unhealthy}`);
@@ -19,6 +19,9 @@ export const STOP_WAIT_MS = 15_000;
19
19
  export const LOST_RECORDS = "업데이트 전 백업 뒤에 생긴 기록(대화 등)은 없어졌을 수 있습니다.";
20
20
  export const UPDATE_AGAIN = "옛 엔진이 공유 폴더 스키마를 읽지 못해 브리지가 멈췄습니다. 다시 업데이트하세요(새 엔진 필요).";
21
21
 
22
+ /** The installer's refusal code (src/engine/install.ts ENGINE_SCHEMA_INCOMPATIBLE) as printed by `setup --engine-only`. */
23
+ export const ENGINE_SCHEMA_REFUSAL = "engine_schema_incompatible";
24
+
22
25
  export interface CommandOutput { readonly code: number; readonly stdout: string; readonly stderr: string }
23
26
 
24
27
  export interface Probe { readonly status: number; readonly omo: string | null; readonly version: string | null }
@@ -143,7 +146,14 @@ export async function runHelper(request: UpdateRequest, deps: HelperDeps): Promi
143
146
  }
144
147
  if (request.engineChanged) {
145
148
  phase("engine");
146
- await bun([join(deps.packageRoot, "src/cli/main.ts"), "setup", "--non-interactive", "--engine-only"], "새 엔진 설치");
149
+ try {
150
+ await bun([join(deps.packageRoot, "src/cli/main.ts"), "setup", "--non-interactive", "--engine-only"], "새 엔진 설치");
151
+ } catch (error) {
152
+ // Findings 214-217: the installer refused before selecting the engine and nothing running was restarted.
153
+ if (!(error instanceof StepError) || !error.message.includes(ENGINE_SCHEMA_REFUSAL)) throw error;
154
+ log(`실패(실행 중인 것은 그대로): ${error.message}`);
155
+ return await untouchedRollback(request, deps, { failure: { untouched: true, message: error.message }, prevTgz, log, phase, bun, finish });
156
+ }
147
157
  }
148
158
  phase("restart");
149
159
  const restarted = await deps.strategy.run({ restart: deps.restart, log, request });
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { CONSENT_WORD, type ConsentRecord, noticeSha256, type PermissionNotice, writePending } from "../consent/consent.ts";
4
- import { type StoreReading, updateSchemaNotice } from "../engine/schema-guard.ts";
4
+ import { SCHEMA_INCOMPATIBLE, storeIncompatibility, type StoreReading, updateSchemaNotice } from "../engine/schema-guard.ts";
5
5
  import type { InstallKind } from "../runtime/install-kind.ts";
6
6
  import { type Origin, REQUEST_FILE, type UpdateRequest, readState, updateDir, updateRunning, writeJson, writeState } from "./state.ts";
7
7
  import { TargetError, type TargetInfo } from "./target.ts";
@@ -21,7 +21,7 @@ export const CHECKOUT_MESSAGE = "이 설치(checkout)는 저장소에서 배포
21
21
 
22
22
  export const DRAINING_MESSAGE = "이전 업데이트가 아직 이전 세대를 정리하고 있습니다. 정리가 끝난 뒤 다시 하세요";
23
23
 
24
- export type RefusalCode = "not_updatable" | "in_progress" | "same_version" | "busy" | "consent_on_mac" | "consent_required" | "schema_confirm" | "pending_questions";
24
+ export type RefusalCode = "not_updatable" | "in_progress" | "same_version" | "busy" | "consent_on_mac" | "consent_required" | "schema_confirm" | "pending_questions" | typeof SCHEMA_INCOMPATIBLE;
25
25
 
26
26
  export interface Refusal {
27
27
  readonly ok: false;
@@ -78,10 +78,23 @@ export function schemaLines(target: TargetInfo, store: StoreReading, globalEngin
78
78
  return notice.confirm ? [...notice.lines, ROLLBACK_SCHEMA_WARNING] : null;
79
79
  }
80
80
 
81
+ /**
82
+ * Findings 214-217: the target's engine cannot read the shared store, so the update is refused before any package
83
+ * install or engine-link change. Neither `force` nor `confirmSchema` overrides it; null when not known to be incompatible.
84
+ */
85
+ export function schemaRefusal(target: TargetInfo, store: StoreReading): Refusal | null {
86
+ const found = storeIncompatibility(store, target.engine?.threadSchema ?? null);
87
+ if (found === null) return null;
88
+ return refuse(SCHEMA_INCOMPATIBLE, `공유 폴더 스키마 ${found.store}를 ${target.version}의 엔진 ${target.engine?.omo ?? ""}(지원 ${found.supported})이 읽지 못해 업데이트하지 않았습니다. 스키마 ${found.store}를 지원하는 버전이 필요합니다`,
89
+ { lines: [`저장소 스키마 ${found.store}`, `대상 엔진 지원 ${found.supported}`] });
90
+ }
91
+
81
92
  export function precheck(input: PrecheckInput): Precheck {
82
93
  if (input.kind !== "npm") return refuse("not_updatable", CHECKOUT_MESSAGE);
83
94
  if (input.running) return refuse("in_progress", "업데이트가 이미 진행 중입니다");
84
95
  if (input.target.version === input.current.version) return refuse("same_version", `이미 ${input.current.version}입니다`);
96
+ const incompatible = schemaRefusal(input.target, input.store);
97
+ if (incompatible !== null) return incompatible;
85
98
  if (input.busy && !input.force && input.target.handoff !== true) return refuse("busy", BUSY_MESSAGE);
86
99
  const notice = input.target.notice;
87
100
  const noticeChanged = input.consent?.noticeVersion !== notice.version;
package/src/work.ts CHANGED
@@ -4,6 +4,7 @@ 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
6
  import { nativeReplyEntry, type NativeReplyEntry, type NativeReplyObserver } from "./reply-notifications.ts";
7
+ import { notificationHistory } from "./notification-history.ts";
7
8
 
8
9
  const ACK_TIMEOUT_MS = 30_000;
9
10
  const PREFIX = "ZGW_WORK_COMMAND ";
@@ -126,11 +127,15 @@ export class WorkRouter {
126
127
  /** Includes finished, normally lazy sources so enabling never backfills their old replies. */
127
128
  async notificationBaseline(): Promise<void> {
128
129
  for (const work of this.allWorks()) {
129
- const binding = await this.ensure(work);
130
+ const generation = this.generation;
130
131
  const revision = this.options.replies?.revision();
131
- const entries = await this.wholeSession(binding.sessionId, () => this.current(work.id, binding));
132
- if (!entries || !this.current(work.id, binding)) throw new Error("reply_baseline_discarded");
133
- if (revision !== undefined) this.options.replies?.observe({ sessionPath: work.sessionPath, conv: work.ownerConv, workId: work.id, title: work.title }, entries, 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");
134
139
  }
135
140
  }
136
141
  private allWorks(): WorkItem[] { return this.store.listConversations().flatMap((conv) => this.store.listWorks(conv.id)); }
@@ -750,10 +755,19 @@ export class WorkRouter {
750
755
  if (!this.ready || generation !== this.generation) return;
751
756
  // A completed report may precede the final assistant reply. Enabled notifications
752
757
  // must recover that tail even when command delivery already reached its terminal state.
753
- if (["completed", "failed", "cancelled"].includes(work.status) &&
754
- this.options.replies?.settings().mode !== "all-replies" &&
755
- !this.store.listWorkCommands(work.id).some((command) => ["pending", "sending", "unknown"].includes(command.delivery))) continue;
756
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
+ }
757
771
  await this.reconcile(work.id);
758
772
  this.schedule(work.id);
759
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.