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.
- package/README.ko.md +90 -19
- package/README.md +91 -19
- package/assets/agent/gateway-request-rules.md +3 -0
- package/assets/agent/gateway-rules.md +6 -0
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/docs/manual.ko.md +69 -1
- package/docs/manual.md +67 -1
- package/host/omo-bridge/task-control.ts +4 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-idle.patch +77 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-intent.patch +212 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-model.patch +299 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-preservation.patch +1063 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-refusal.patch +83 -0
- package/host/omo-check/patches/omo-5.1.35-gateway-port.manifest.json +169 -7
- package/host/omo-check/patches/omo-5.1.35-request-rules.patch +109 -0
- package/host/omo-check/patches/omo-5.1.35-turn-stats.patch +76 -0
- package/host/omo-check/patches/omo-5.1.44-compaction-port.patch +2630 -0
- package/host/omo-check/patches/omo-5.1.44-gateway-port.manifest.json +329 -0
- package/host/omo-check/patches/omo-5.1.44-provider-port.patch +440 -0
- package/host/omo-check/patches/omo-5.1.44-rpc-port.patch +778 -0
- package/package.json +4 -1
- package/scripts/secret-rules.ts +101 -0
- package/scripts/secret-scan.allow.json +155 -0
- package/scripts/secret-scan.rules.json +30 -0
- package/src/agent-request-rules.ts +82 -0
- package/src/cli/access.ts +31 -34
- package/src/cli/launchd.ts +30 -4
- package/src/cli/main.ts +3 -43
- package/src/cli/messages.ts +160 -0
- package/src/cli/pair.ts +32 -25
- package/src/cli/setup.ts +164 -75
- package/src/decisions.ts +48 -10
- package/src/engine/compat.json +3 -3
- package/src/hub.ts +183 -47
- package/src/main.ts +60 -4
- package/src/notification-history.ts +56 -0
- package/src/omo/client.ts +28 -0
- package/src/protocol.ts +32 -2
- package/src/reply-notifications.ts +144 -0
- package/src/runtime/paths.ts +2 -0
- package/src/security-allow.ts +126 -0
- package/src/security-check.ts +559 -0
- package/src/store.ts +236 -6
- package/src/turn-stats.ts +49 -0
- package/src/work-watch.ts +36 -10
- package/src/work.ts +45 -6
- package/web/DESIGN.md +39 -0
- package/web/app.js +289 -2
- package/web/decisions.d.ts +1 -1
- package/web/decisions.js +6 -0
- package/web/diagram.d.ts +28 -0
- package/web/diagram.js +228 -0
- package/web/index.html +29 -0
- package/web/notifications.d.ts +25 -0
- package/web/notifications.js +118 -0
- package/web/security-inbox.d.ts +35 -0
- package/web/security-inbox.js +237 -0
- package/web/style.css +43 -1
- package/web/turn-stats.d.ts +26 -0
- 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
|
-
/**
|
|
981
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
355
|
-
|
|
356
|
-
(
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|
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), () =>
|
|
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
|
-
//
|
|
729
|
-
|
|
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.
|