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/main.ts CHANGED
@@ -4,6 +4,7 @@ import { join, normalize } from "node:path";
4
4
  import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
5
5
  import { createSttProvider } from "./adapters/voice/stt.ts";
6
6
  import { createTtsProvider, speakableText, splitForTts } from "./adapters/voice/tts.ts";
7
+ import { handleHeld, handleSecurityAllowedValues, handleSecurityCheckSetting, MessageGuard, SECURITY_CHECK_PATH, SECURITY_HELD_PATH, SECURITY_ALLOWED_PATH, SecurityCheckError, defaultChecker } from "./security-check.ts";
7
8
  import { handleVoiceInstall, handleVoiceSetting, routeVoiceApi, VOICE_INSTALL_PATH, VOICE_SETTING_PATH, voiceGate, voiceUpdated } from "./voice-api.ts";
8
9
  import { readVoiceSetting, VoiceSetting } from "./voice-setting.ts";
9
10
  import { applyVoiceAgents } from "./voice/agents.ts";
@@ -46,6 +47,7 @@ import { RECOVERY_RECEIPT_PREFIX } from "./omo/recovery-receipt.ts";
46
47
  import { fileScheduleSource, notionRoutineSource, ReminderLoop } from "./schedule/reminders.ts";
47
48
  import { type ClientMessage, type Envelope, PROTOCOL_VERSION, ROTATION_HELD_MESSAGE, type Source } from "./protocol.ts";
48
49
  import { QuestionNotifications } from "./question-notifications.ts";
50
+ import { NotificationSettingsApi, ReplyNotifications } from "./reply-notifications.ts";
49
51
  import { DecisionStoreError, Store } from "./store.ts";
50
52
  import { TranscriptLog } from "./transcript.ts";
51
53
  import { WorkRouter } from "./work.ts";
@@ -145,11 +147,26 @@ const updateApi: UpdateApiDeps = {
145
147
  };
146
148
 
147
149
  let publish: (envelope: Envelope) => void = () => {};
150
+ const replyNotifications = new ReplyNotifications<ServerWebSocket<Conn>>(store, (ws, notification) => {
151
+ if (ws.data.deviceId && store.getDevice(ws.data.deviceId)?.status === "approved") ws.send(envelope("reply.notification", notification, notification.conv));
152
+ });
153
+ let securityDecisionRefresh = () => {};
154
+ const securityEvent = (type: string, data: unknown) => publish({
155
+ v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(), conv: "*", type, data,
156
+ });
157
+ const messageGuard = new MessageGuard(defaultChecker(), runtimePaths().securityCheckFile, (held) => publish({
158
+ v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(), conv: held.conv, type: "security-check.held", data: held,
159
+ }), { store, state: (state) => {
160
+ securityEvent("security-check.state", state);
161
+ if (state.status === "sent" || state.status === "cancelled") securityEvent("security-check.resolved", { id: state.id, outcome: state.status === "sent" ? "sent" : "dropped" });
162
+ }, allowedUpdated: (revision) => securityEvent("security-check.allowed-values.updated", { revision }),
163
+ decisionUpdated: () => securityDecisionRefresh() });
148
164
  const hub = new Hub(
149
165
  store,
150
166
  dialogs,
151
167
  stt,
152
168
  {
169
+ replies: replyNotifications,
153
170
  cwd: config.omoCwd,
154
171
  sessionDir: config.omoSessionDir,
155
172
  permissionPreset: config.omoPermissionPreset,
@@ -164,6 +181,7 @@ const hub = new Hub(
164
181
  build: process.env.ZGW_BUILD_SHA,
165
182
  }),
166
183
  usageReport: createUsageReport({ dir: agentDir(), inContainer: existsSync("/.dockerenv") }),
184
+ guard: messageGuard,
167
185
  },
168
186
  (e) => publish(e),
169
187
  );
@@ -205,7 +223,7 @@ const omo = new GenerationRouter(startup.endpoints, startup.active, {
205
223
  onEvent: (e) => {
206
224
  if (work.onEvent(e)) return;
207
225
  summarizer.onEvent(e, hub.ownsSession(e.sessionId));
208
- hub.onEvent(e);
226
+ void Promise.resolve(hub.onEvent(e)).catch((error) => console.error("native event:", error));
209
227
  },
210
228
  onStatus: (s, d) => {
211
229
  const generation = ++connectionGeneration;
@@ -249,10 +267,12 @@ function releaseRecovery(): void {
249
267
  })().finally(() => { releaseRunning = null; });
250
268
  }
251
269
  const work = new WorkRouter(store, omo, {
270
+ replies: replyNotifications,
252
271
  sessionDir: config.omoSessionDir,
253
272
  permissionPreset: config.omoPermissionPreset,
254
273
  onPublished(event) {
255
274
  hub.publishStored(event);
275
+ replyNotifications.deliver();
256
276
  // A published work report raises that work's `reported` in the work status.
257
277
  workStatus.schedule();
258
278
  },
@@ -285,6 +305,12 @@ const work = new WorkRouter(store, omo, {
285
305
  else hub.handleScopedUiRequest(owner, event);
286
306
  },
287
307
  });
308
+ const notificationSettingsApi = new NotificationSettingsApi(replyNotifications, async () => {
309
+ await hub.notificationBaseline();
310
+ await work.notificationBaseline();
311
+ }, (data) => publish({
312
+ v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(), conv: "*", type: "notifications.updated", data,
313
+ }));
288
314
  hub.attachOmo(omo);
289
315
  const omoCheck = new OmoCheckScheduler(store, {
290
316
  cwd: process.env.ZGW_OMO_CHECK_CWD ?? "",
@@ -302,7 +328,8 @@ const decisions = new DecisionService(store, {
302
328
  }, (data) => publish({
303
329
  v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(),
304
330
  conv: data.decision.ownerConv, type: "decision.updated", data,
305
- }), { onHistory: (event) => hub.publishStored(event) });
331
+ }), { onHistory: (event) => hub.publishStored(event), guard: messageGuard });
332
+ securityDecisionRefresh = () => decisions.refresh();
306
333
  hub.attachDecisions(decisions);
307
334
  const summarizer = new SpeechSummarizer(omo, {
308
335
  sessionDir: config.omoSessionDir,
@@ -373,6 +400,7 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
373
400
  ws.data.notifyOnly &&
374
401
  msg.type !== "hello" &&
375
402
  msg.type !== "reminder.ack" &&
403
+ msg.type !== "reply.notification.ack" &&
376
404
  msg.type !== "question.notification.ack"
377
405
  ) return;
378
406
  if (
@@ -403,6 +431,7 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
403
431
  if (state.omo === "ready") questionNotifications.sync(snapshot.approvals);
404
432
  if (ws.data.notifyOnly) {
405
433
  if (ws.data.deviceId) questionNotifications.connectDevice(ws, ws.data.deviceId);
434
+ if (ws.data.deviceId) replyNotifications.connectDevice(ws, ws.data.deviceId);
406
435
  for (const r of reminders.pending(Date.now())) ws.send(envelope("reminder", r));
407
436
  return;
408
437
  }
@@ -423,6 +452,10 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
423
452
  case "reminder.ack":
424
453
  if (typeof msg.data?.id === "string") reminders.ack(msg.data.id, Date.now());
425
454
  return;
455
+ case "reply.notification.ack":
456
+ if (ws.data.notifyOnly && ws.data.deviceId && typeof msg.data?.id === "string" &&
457
+ store.getDevice(ws.data.deviceId)?.status === "approved") replyNotifications.ack(ws, msg.data.id);
458
+ return;
426
459
  case "question.notification.ack": {
427
460
  const id = ws.data.deviceId;
428
461
  if (
@@ -452,6 +485,8 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
452
485
  "owner",
453
486
  Array.isArray(msg.data.images) ? msg.data.images.filter((n): n is string => typeof n === "string") : [],
454
487
  msg.data.references,
488
+ false,
489
+ msg.data.submissionId,
455
490
  ),
456
491
  );
457
492
  return;
@@ -461,6 +496,9 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
461
496
  ws.send(envelope("error", { message: "approval_not_pending", actionId: msg.data.actionId }, msg.conv));
462
497
  }
463
498
  } catch (error) {
499
+ if (error instanceof SecurityCheckError) {
500
+ return;
501
+ }
464
502
  if (!(error instanceof DecisionStoreError)) throw error;
465
503
  ws.send(envelope("error", {
466
504
  message: error.code, actionId: msg.data.actionId,
@@ -610,6 +648,7 @@ async function handle(req: Request, srv: Server<Conn>, trusted: boolean): Promis
610
648
  return srv.upgrade(req, { data: { trusted, deviceId, notifyOnly, webClient: false } }) ? undefined : new Response("upgrade failed", { status: 400 });
611
649
  }
612
650
  // Voice off answers 503 before native admission and before any body is read.
651
+ if (url.pathname === "/api/settings/notifications") return notificationSettingsApi.handle(req);
613
652
  const voiceDisabled = voiceGate(req, url, voiceSetting);
614
653
  if (voiceDisabled) return voiceDisabled;
615
654
  if (url.pathname === VOICE_INSTALL_PATH) {
@@ -623,6 +662,20 @@ async function handle(req: Request, srv: Server<Conn>, trusted: boolean): Promis
623
662
  });
624
663
  if (response) return response;
625
664
  }
665
+ if (url.pathname === SECURITY_HELD_PATH || url.pathname.startsWith(`${SECURITY_HELD_PATH}/`)) {
666
+ const response = await handleHeld(req, url, messageGuard, () => {}, { client: trusted ? "web" : "android", deviceId });
667
+ if (response) return response;
668
+ }
669
+ if (url.pathname === SECURITY_ALLOWED_PATH || url.pathname.startsWith(`${SECURITY_ALLOWED_PATH}/`)) {
670
+ const response = await handleSecurityAllowedValues(req, url, store, (revision) => securityEvent("security-check.allowed-values.updated", { revision }));
671
+ if (response) return response;
672
+ }
673
+ if (url.pathname === SECURITY_CHECK_PATH) {
674
+ const response = await handleSecurityCheckSetting(req, trusted, messageGuard, (enabled) => publish({
675
+ v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(), conv: "*", type: "security-check.updated", data: { enabled },
676
+ }));
677
+ if (response) return response;
678
+ }
626
679
  if (url.pathname === PROFILE_ICON_PATH) {
627
680
  const response = await handleProfileIcon(req, url, trusted, { icon: profileIcon, profile: () => readProfile(store), publish: (e) => publish(e) });
628
681
  if (response) return response;
@@ -827,6 +880,7 @@ const websocket: WebSocketHandler<Conn> = {
827
880
  close(ws) {
828
881
  if (ws.data.deviceId) deviceSockets.get(ws.data.deviceId)?.delete(ws);
829
882
  questionNotifications.disconnectDevice(ws);
883
+ replyNotifications.disconnectDevice(ws);
830
884
  questionNotifications.disconnectWeb(ws);
831
885
  },
832
886
  };
@@ -837,8 +891,9 @@ const lanServer = config.lanPort
837
891
  : null;
838
892
 
839
893
  publish = (e) => {
840
- if (!omoCheckNotifications.allow(e)) return;
841
- questionNotifications.onEnvelope(e);
894
+ const sensitiveHeld = e.type === "security-check.held";
895
+ if (!sensitiveHeld && !omoCheckNotifications.allow(e)) return;
896
+ if (!sensitiveHeld) questionNotifications.onEnvelope(e);
842
897
  const text = JSON.stringify(e);
843
898
  const topic = topicOf(e.type);
844
899
  trustedServer.publish(topic, text);
@@ -885,6 +940,7 @@ function shutdown(): void {
885
940
  updateChecker.stop();
886
941
  clearInterval(updateTick);
887
942
  decisions.stop();
943
+ messageGuard.stop();
888
944
  watcher.stop();
889
945
  work.stop();
890
946
  omo.stop();
@@ -0,0 +1,56 @@
1
+ import { open, stat } from "node:fs/promises";
2
+ import { nativeReplyEntry, type NativeReplyEntry } from "./reply-notifications.ts";
3
+
4
+ /**
5
+ * Read only the owned native JSONL, never the disposable resident blobs or an opened native session.
6
+ * The byte snapshot is finite; memory is bounded by a chunk plus the largest JSONL record, not the history.
7
+ */
8
+ export async function notificationHistory(
9
+ path: string,
10
+ visit: (entry: NativeReplyEntry) => void,
11
+ baseline = false,
12
+ ): Promise<void> {
13
+ let file;
14
+ try { file = await open(path, "r"); }
15
+ catch (error) {
16
+ // Native buffers setup-only sessions until the first user/assistant message.
17
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
18
+ throw error;
19
+ }
20
+ try {
21
+ const snapshot = await file.stat();
22
+ const chunk = Buffer.alloc(64 * 1024);
23
+ const decoder = new TextDecoder("utf-8", { fatal: true });
24
+ let offset = 0;
25
+ let tail = "";
26
+ let header = false;
27
+ while (offset < snapshot.size) {
28
+ const { bytesRead } = await file.read(chunk, 0, Math.min(chunk.length, snapshot.size - offset), offset);
29
+ if (bytesRead === 0) throw new Error("reply_history_truncated");
30
+ offset += bytesRead;
31
+ tail += decoder.decode(chunk.subarray(0, bytesRead), { stream: true });
32
+ let newline: number;
33
+ while ((newline = tail.indexOf("\n")) !== -1) {
34
+ const line = tail.slice(0, newline);
35
+ tail = tail.slice(newline + 1);
36
+ const value: unknown = JSON.parse(line);
37
+ const entry = nativeReplyEntry(value);
38
+ if (!entry) throw new Error("reply_history_invalid_entry");
39
+ if (!header) {
40
+ if (entry.type !== "session") throw new Error("reply_history_invalid_header");
41
+ header = true;
42
+ } else {
43
+ if (entry.type === "session") throw new Error("reply_history_duplicate_header");
44
+ visit(baseline ? { id: entry.id, type: entry.type } : entry);
45
+ }
46
+ }
47
+ }
48
+ tail += decoder.decode();
49
+ if (!header || tail.length !== 0) throw new Error("reply_history_incomplete");
50
+ const current = await stat(path);
51
+ if (current.dev !== snapshot.dev || current.ino !== snapshot.ino || current.size < snapshot.size ||
52
+ (current.size === snapshot.size && current.mtimeMs !== snapshot.mtimeMs)) throw new Error("reply_history_replaced");
53
+ } finally {
54
+ await file.close();
55
+ }
56
+ }
package/src/omo/client.ts CHANGED
@@ -10,6 +10,34 @@ export interface OmoEvent {
10
10
  [key: string]: unknown;
11
11
  }
12
12
 
13
+ /** Public S3 terminal categories. Native diagnostics never cross the UI boundary. */
14
+ export const COMPACTION_FAILURE_KINDS = [
15
+ "invalid_model", "auth_missing", "auth_expired", "quota", "org_denied", "model_unavailable",
16
+ "refused", "timeout", "incomplete", "empty", "context_budget", "stale", "provider",
17
+ ] as const;
18
+ export type CompactionFailureKind = typeof COMPACTION_FAILURE_KINDS[number];
19
+ export type CompactionOutcome =
20
+ | { readonly status: "failed"; readonly failureKind: CompactionFailureKind }
21
+ | { readonly status: "cancelled" | "completed" };
22
+
23
+ export function blockingCompaction(event: OmoEvent): boolean {
24
+ return event.blocking !== false && event.reason !== "speculative" && event.reason !== "idle";
25
+ }
26
+
27
+ /** Old engines have no structured failure; do not classify their raw error strings. */
28
+ export function compactionOutcome(event: OmoEvent): CompactionOutcome {
29
+ const failure = event.failure;
30
+ const structured = typeof failure === "object" && failure !== null;
31
+ if (event.aborted === true && !structured && event.rejectionCause !== "cancelled-by-extension") return { status: "cancelled" };
32
+ const result = typeof event.result === "object" && event.result !== null && !Array.isArray(event.result);
33
+ const failed = structured || typeof event.errorMessage === "string" || event.accepted === false ||
34
+ (event.accepted !== true && !result);
35
+ if (!failed) return { status: "completed" };
36
+ const kind = structured && "kind" in failure ? failure.kind : undefined;
37
+ for (const known of COMPACTION_FAILURE_KINDS) if (kind === known) return { status: "failed", failureKind: known };
38
+ return { status: "failed", failureKind: "provider" };
39
+ }
40
+
13
41
  export interface OmoResponse {
14
42
  type: "response";
15
43
  id: string;
package/src/protocol.ts CHANGED
@@ -19,6 +19,22 @@ export interface MessageReference {
19
19
  readonly images?: readonly string[];
20
20
  }
21
21
 
22
+ /** Measured across one native response turn, including its model calls around tools. */
23
+ export interface TurnStats {
24
+ readonly tokensPerSecond?: number;
25
+ /** Sum of assistant/model processing intervals, not tool or total wall time. */
26
+ readonly modelDurationMs?: number;
27
+ /** Missing cache usage is unknown, not zero. */
28
+ readonly cacheHitPercent?: number;
29
+ }
30
+
31
+ export interface MessageStatsData {
32
+ readonly itemId: string;
33
+ readonly messageSeq: number;
34
+ readonly turnId: string;
35
+ readonly stats: TurnStats;
36
+ }
37
+
22
38
  export interface Envelope<T extends string = string, D = unknown> {
23
39
  v: typeof PROTOCOL_VERSION;
24
40
  id: string;
@@ -69,6 +85,7 @@ export type DecisionAnswerRequest = { readonly expectedRevision: number; readonl
69
85
 
70
86
  /** HTTP/event DTOs are separate from storage records; payload retains the existing action-card structure. */
71
87
  export interface DecisionInfo {
88
+ readonly security?: import("./security-allow.ts").HeldState;
72
89
  readonly id: string;
73
90
  readonly revision: number;
74
91
  readonly ownerConv: string;
@@ -118,14 +135,27 @@ export interface DecisionUpdatedData {
118
135
  readonly counters: DecisionCountersInfo;
119
136
  }
120
137
 
138
+ export type NotificationMode = "inbox-only" | "all-replies";
139
+ export interface NotificationSettings {
140
+ readonly mode: NotificationMode;
141
+ }
142
+ export interface ReplyNotification {
143
+ readonly id: string;
144
+ readonly conv: string;
145
+ readonly title: string;
146
+ readonly text: string;
147
+ readonly workId?: string;
148
+ }
149
+
121
150
  export type ClientMessage =
122
151
  | { type: "hello"; data: { client: string; version: string } }
123
152
  | { type: "client.presence"; data: { visible: boolean } }
124
153
  | { type: "history.get"; conv: string; before?: number; after?: number; limit?: number }
125
154
  | { type: "conversation.create"; data: { title: string } }
126
- | { type: "input.text"; conv: string; data: { text: string; images?: string[]; references?: number[] } }
155
+ | { type: "input.text"; conv: string; data: { text: string; images?: string[]; references?: number[]; submissionId?: string } }
127
156
  | { type: "input.action"; conv: string; data: ActionAnswerData }
128
157
  | { type: "input.interrupt"; conv: string; data: Record<string, never> }
129
158
  | { type: "conversation.rotate"; conv: string }
130
159
  | { type: "reminder.ack"; data: { id: string } }
131
- | { type: "question.notification.ack"; data: { id: string } };
160
+ | { type: "question.notification.ack"; data: { id: string } }
161
+ | { type: "reply.notification.ack"; data: { id: string } };
@@ -0,0 +1,144 @@
1
+ import type { NotificationSettings, ReplyNotification } from "./protocol.ts";
2
+ import type { Store } from "./store.ts";
3
+
4
+ export interface NativeReplySource {
5
+ readonly sessionPath: string;
6
+ readonly conv: string;
7
+ readonly workId?: string;
8
+ readonly title: string;
9
+ }
10
+
11
+ export interface NativeReplyEntry {
12
+ readonly id?: string;
13
+ readonly type?: string;
14
+ readonly message?: {
15
+ readonly role?: string;
16
+ readonly content?: unknown;
17
+ readonly stopReason?: string;
18
+ readonly errorMessage?: string;
19
+ };
20
+ }
21
+
22
+ /** entry_appended carries a persisted entry, unlike message_end's identity-free message object. */
23
+ export function nativeReplyEntry(value: unknown): NativeReplyEntry | null {
24
+ if (typeof value !== "object" || value === null || !("id" in value) || typeof value.id !== "string" || !value.id ||
25
+ !("type" in value) || typeof value.type !== "string") return null;
26
+ const message = "message" in value ? value.message : undefined;
27
+ if (typeof message !== "object" || message === null) return { id: value.id, type: value.type };
28
+ return {
29
+ id: value.id, type: value.type,
30
+ message: {
31
+ role: "role" in message && typeof message.role === "string" ? message.role : undefined,
32
+ stopReason: "stopReason" in message && typeof message.stopReason === "string" ? message.stopReason : undefined,
33
+ errorMessage: "errorMessage" in message && typeof message.errorMessage === "string" ? message.errorMessage : undefined,
34
+ content: "content" in message ? message.content : undefined,
35
+ },
36
+ };
37
+ }
38
+
39
+ function replyText(entry: NativeReplyEntry): string | null {
40
+ const message = entry.type === "message" ? entry.message : undefined;
41
+ // Only native completed assistant messages qualify, including intermediate tool-use messages.
42
+ if (message?.role !== "assistant" || !["stop", "toolUse", "length"].includes(message.stopReason ?? "") || message.errorMessage) return null;
43
+ const content = message.content;
44
+ const text = typeof content === "string" ? content : Array.isArray(content)
45
+ ? content.flatMap((part: unknown) => typeof part === "object" && part !== null &&
46
+ "type" in part && part.type === "text" && "text" in part && typeof part.text === "string" ? [part.text] : []).join("")
47
+ : "";
48
+ return text.trim() ? text : null;
49
+ }
50
+
51
+ export class ReplyNotifications<Connection extends object> {
52
+ private readonly devices = new Map<Connection, { deviceId: string; sent: Set<string> }>();
53
+
54
+ constructor(private readonly store: Store, private readonly send: (connection: Connection, notification: ReplyNotification) => void) {}
55
+
56
+ settings(): NotificationSettings {
57
+ return { mode: this.store.getSetting("notifications.mode") === "all-replies" ? "all-replies" : "inbox-only" };
58
+ }
59
+
60
+ revision(): string {
61
+ return this.store.getSetting("notifications.revision") ?? "0";
62
+ }
63
+
64
+ /** The read's revision is captured before RPC, so a toggle cannot admit an older in-flight read. */
65
+ observe(source: NativeReplySource, entries: readonly NativeReplyEntry[], revision: string): void {
66
+ if (revision !== this.revision()) return;
67
+ for (const entry of entries) {
68
+ if (typeof entry.id !== "string" || !entry.id) throw new Error("reply_history_entry_without_id");
69
+ const text = replyText(entry);
70
+ const notification = this.settings().mode === "all-replies" && text !== null ? {
71
+ id: crypto.randomUUID(), conv: source.conv, title: source.title, text,
72
+ ...(source.workId ? { workId: source.workId } : {}),
73
+ } : null;
74
+ this.store.recordReplyNotification(source.sessionPath, entry.id, notification);
75
+ }
76
+ this.deliver();
77
+ }
78
+
79
+ setMode(mode: NotificationSettings["mode"]): void {
80
+ if (mode === this.settings().mode) return;
81
+ this.store.setNotificationMode(mode);
82
+ }
83
+
84
+ connectDevice(connection: Connection, deviceId: string): void {
85
+ if (this.devices.get(connection)?.deviceId === deviceId) return;
86
+ this.devices.set(connection, { deviceId, sent: new Set() });
87
+ this.deliver();
88
+ }
89
+
90
+ disconnectDevice(connection: Connection): void {
91
+ this.devices.delete(connection);
92
+ this.deliver();
93
+ }
94
+
95
+ ack(connection: Connection, id: string): boolean {
96
+ const device = this.devices.get(connection);
97
+ if (!device?.sent.has(id)) return false;
98
+ this.store.ackReplyNotification(id, device.deviceId);
99
+ return true;
100
+ }
101
+
102
+ /** Flushes committed occurrences, including explicitly published work replies. */
103
+ deliver(): void {
104
+ if (this.settings().mode !== "all-replies") return;
105
+ for (const [connection, device] of this.devices) {
106
+ for (const notification of this.store.pendingReplyNotifications(device.deviceId)) {
107
+ if ([...this.devices.values()].some((other) => other.deviceId === device.deviceId && other.sent.has(notification.id))) continue;
108
+ device.sent.add(notification.id);
109
+ this.send(connection, notification);
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ /** Native owners use this narrow seam; notification transport never observes synthetic chat publication. */
116
+ export type NativeReplyObserver = Pick<ReplyNotifications<object>, "observe" | "revision" | "settings">;
117
+
118
+ /** Writes serialize across awaited activation snapshots; rejected requests never reach the queue. */
119
+ export class NotificationSettingsApi {
120
+ private writes: Promise<unknown> = Promise.resolve();
121
+
122
+ constructor(private readonly notifications: Pick<ReplyNotifications<object>, "settings" | "setMode">,
123
+ private readonly baseline: () => Promise<void>, private readonly publish: (settings: NotificationSettings) => void) {}
124
+
125
+ async handle(req: Request): Promise<Response> {
126
+ if (req.method === "GET") return Response.json(this.notifications.settings());
127
+ if (req.method !== "PUT") return Response.json({ error: "method_not_allowed" }, { status: 405 });
128
+ const body: unknown = await req.json().catch(() => null);
129
+ if (typeof body !== "object" || body === null || Array.isArray(body) || Object.keys(body).length !== 1 ||
130
+ !("mode" in body) || (body.mode !== "inbox-only" && body.mode !== "all-replies")) {
131
+ return Response.json({ error: "invalid_notification_settings" }, { status: 400 });
132
+ }
133
+ const mode = body.mode;
134
+ const write = this.writes.then(async () => {
135
+ if (mode === "all-replies" && this.notifications.settings().mode !== mode) await this.baseline();
136
+ this.notifications.setMode(mode);
137
+ const settings = this.notifications.settings();
138
+ this.publish(settings);
139
+ return Response.json(settings);
140
+ });
141
+ this.writes = write.catch(() => undefined);
142
+ return write;
143
+ }
144
+ }
@@ -41,6 +41,7 @@ export interface RuntimePaths {
41
41
  readonly backups: string;
42
42
  readonly scheduleFile: string;
43
43
  readonly voiceSettingFile: string;
44
+ readonly securityCheckFile: string;
44
45
  readonly logs: string;
45
46
  readonly secrets: string;
46
47
  readonly omoTokenFile: string;
@@ -108,6 +109,7 @@ export function runtimePaths(env: Env = process.env, exists: (path: string) => b
108
109
  backups: join(data, "backups"),
109
110
  scheduleFile: value(env, "ZGW_SCHEDULE_FILE") ?? join(data, "schedule.json"),
110
111
  voiceSettingFile: value(env, "ZGW_VOICE_SETTING_FILE") ?? join(data, "voice-setting"),
112
+ securityCheckFile: value(env, "ZGW_SECURITY_CHECK_FILE") ?? join(data, "security-check"),
111
113
  logs: join(home, "logs"),
112
114
  secrets,
113
115
  omoTokenFile: value(env, "ZGW_OMO_TOKEN_FILE") ?? join(secrets, "omo-ws-token"),
@@ -0,0 +1,126 @@
1
+ /** Runtime-only contract. Repository and release scanners never import this module. */
2
+ export type HeldStatus = "pending" | "processing" | "sent" | "cancelled" | "failed" | "unconfirmed";
3
+ export type SecurityErrorCode = "allowlist_persist_failed" | "state_persist_failed" | "payload_lost" | "dispatch_failed" | "dispatch_unknown" | "original_decision_conflict";
4
+ export interface HeldState {
5
+ readonly id: string;
6
+ readonly decisionId: string;
7
+ readonly revision: number;
8
+ readonly conv: string;
9
+ readonly source: "message" | "answer";
10
+ readonly createdAt: string;
11
+ readonly originalDecisionId: string | null;
12
+ readonly status: HeldStatus;
13
+ readonly submissionId: string | null;
14
+ readonly remembered: boolean;
15
+ readonly allowlistRevision: number;
16
+ readonly errorCode: SecurityErrorCode | null;
17
+ }
18
+ export interface SecurityApprover {
19
+ readonly client: "web" | "android";
20
+ readonly deviceId: string | null;
21
+ }
22
+ export interface AllowedValue {
23
+ readonly id: string;
24
+ readonly value: string;
25
+ readonly kind: string;
26
+ readonly label: string;
27
+ readonly approvedAt: string;
28
+ readonly approvedBy: SecurityApprover;
29
+ readonly heldId: string;
30
+ }
31
+ export interface SecurityIntent {
32
+ readonly send: boolean;
33
+ readonly remember: boolean;
34
+ readonly expectedRevision: number | null;
35
+ readonly submissionId: string | null;
36
+ }
37
+ export type SecurityReceipt =
38
+ | { readonly kind: "message"; readonly conv: string; readonly messageSeq: number }
39
+ | { readonly kind: "answer"; readonly decisionId: string; readonly submissionId: string };
40
+ export interface SecurityHoldRecord {
41
+ readonly state: HeldState;
42
+ readonly key: string | null;
43
+ readonly intent: SecurityIntent | null;
44
+ readonly dispatchReserved: boolean;
45
+ readonly receipt: SecurityReceipt | null;
46
+ }
47
+ export class SecurityRuntimeError extends Error {
48
+ constructor(
49
+ readonly code: "held_conflict" | "allowlist_conflict" | SecurityErrorCode,
50
+ readonly held?: HeldState,
51
+ readonly revision?: number,
52
+ ) { super(code); }
53
+ }
54
+ export const securityDecision = (decision: { readonly payload: unknown }): boolean =>
55
+ typeof decision.payload === "object" && decision.payload !== null &&
56
+ "kind" in decision.payload && decision.payload.kind === "security-check";
57
+
58
+ /** A delimiter is meaningful only if its matching quote can be proved. No decoding or normalization. */
59
+ function quotedEnd(text: string, start: number, quote: string): number | null {
60
+ for (let i = start; i < text.length; i++) {
61
+ if (text[i] === "\\") { i++; continue; }
62
+ if (text[i] === quote) return i;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ /** Expand an existing detector hit; this function never decides whether a hit exists. */
68
+ export function canonicalSecurityValue(text: string, rule: string, start: number, end: number): string {
69
+ const lineStart = text.lastIndexOf("\n", start - 1) + 1;
70
+ const nextLine = text.indexOf("\n", end);
71
+ const lineEnd = nextLine < 0 ? text.length : nextLine;
72
+ // A PEM or SSH record owns every nested hit, including an address in its comment.
73
+ for (const match of text.matchAll(/-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----/g)) {
74
+ const begin = match.index;
75
+ const closing = `-----END ${match[1]}-----`;
76
+ const at = text.indexOf(closing, begin + match[0].length);
77
+ const finish = at < 0 ? text.length : at + closing.length;
78
+ if (start >= begin && start < finish) return text.slice(begin, finish);
79
+ }
80
+ const line = text.slice(lineStart, lineEnd).replace(/\r$/, "");
81
+ if (/(?:ssh-(?:rsa|ed25519|dss)|ecdsa-sha2-[A-Za-z0-9-]+|sk-(?:ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com)[ \t]+[A-Za-z0-9+/]{20,}/.test(line)) return line;
82
+ // Locate quoted units and keep their original escape bytes.
83
+ let enclosing: { start: number; end: number | null } | undefined;
84
+ for (let i = lineStart; i < start; i++) {
85
+ const ch = text[i];
86
+ if (ch !== "'" && ch !== '"' && ch !== "`") continue;
87
+ const finish = quotedEnd(text, i + 1, ch);
88
+ if (finish === null || finish >= end) { enclosing = { start: i + 1, end: finish }; break; }
89
+ i = finish;
90
+ }
91
+ if (enclosing?.end === null) return text.slice(enclosing.start);
92
+ const header = /\b(?:authorization["']?\s*[:=]\s*["']?(?:Bearer|Basic)\s+|(?:set-cookie|cookie)["']?\s*[:=]\s*["']?)/i.exec(line);
93
+ if (header) {
94
+ const valueStart = lineStart + header.index + header[0].length;
95
+ if (start >= valueStart) return text.slice(valueStart, enclosing?.end ?? lineEnd).replace(/\r$/, "");
96
+ }
97
+ if (enclosing && enclosing.end !== null && /[:=]\s*$/.test(text.slice(lineStart, enclosing.start - 1))) {
98
+ return text.slice(enclosing.start, enclosing.end);
99
+ }
100
+ // URLs take precedence over addresses, token fragments and assignment captures inside them.
101
+ for (const match of text.matchAll(/[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s"'`<>]+/g)) {
102
+ if (start >= match.index && start < match.index + match[0].length) {
103
+ if (enclosing && enclosing.end !== null) return text.slice(match.index, enclosing.end);
104
+ return match[0];
105
+ }
106
+ }
107
+ // A path owns nested host/token captures. Quoted paths may contain spaces.
108
+ for (const match of text.matchAll(/\/(?:Users|home)\/[^\s"'`<>]*/g)) {
109
+ const finish = enclosing && enclosing.end !== null && match.index >= enclosing.start ?
110
+ enclosing.end : match.index + match[0].length;
111
+ if (start >= match.index && start < finish) return text.slice(match.index, finish);
112
+ }
113
+ if (["high-entropy-assignment", "credential-assignment", "device-serial", "authorization", "cookie-header"].includes(rule)) {
114
+ if (enclosing && enclosing.end !== null) return text.slice(start, enclosing.end);
115
+ if (rule === "authorization" || rule === "cookie-header") return text.slice(start, lineEnd).replace(/\r$/, "");
116
+ let finish = end;
117
+ while (finish < text.length && !/[\s,;}]/.test(text[finish] ?? "")) finish++;
118
+ return text.slice(start, finish);
119
+ }
120
+ if (["github-token", "npm-token", "anthropic-key", "openai-key", "notion-token", "slack-token", "aws-access-key", "gcp-api-key", "tailscale-key", "jwt"].includes(rule)) {
121
+ let finish = end;
122
+ while (finish < text.length && /[A-Za-z0-9_+/.=-]/.test(text[finish] ?? "")) finish++;
123
+ return text.slice(start, finish);
124
+ }
125
+ return text.slice(start, end);
126
+ }