@zeph-to/cli 2.15.0 → 2.17.0

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.
@@ -0,0 +1,280 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.diskTurnRing = exports.removeTurnRing = exports.sweepTurnRings = exports.readTurnRing = exports.appendTurnRing = exports.turnRingPath = exports.turnRingDir = exports.TURN_RING_TTL_MS = exports.MAX_TURN_RING_FILES = exports.MAX_TURN_RING_BYTES = void 0;
4
+ const fs_1 = require("fs");
5
+ const crypto_1 = require("crypto");
6
+ const path_1 = require("path");
7
+ const gate_js_1 = require("./gate.js");
8
+ /**
9
+ * What the daemon has already sent a chat viewer, kept on disk so a
10
+ * re-subscribe can replay it.
11
+ *
12
+ * Why this exists at all: the live lane is ephemeral, and the transcript
13
+ * backfill deliberately replays only since the last prompt — finished turns are
14
+ * supposed to already exist in the chat as their completion pushes. Under the
15
+ * `quiet` push dial they do not exist, because that dial suppresses every
16
+ * auto-push without a `high` marker. A turn's closing words were therefore
17
+ * visible while it ran and gone the moment the viewer re-armed, with no record
18
+ * on the phone, on the server, or in the 256KB backfill window.
19
+ *
20
+ * Re-deriving those turns from the transcript is not an option: measured over
21
+ * this machine's transcripts larger than the window, the last 256KB holds a
22
+ * median of one human prompt and none at all in 8 of 19 files — a single
23
+ * `tool_result` line can be most of a megabyte. Projected events are a tool
24
+ * name, a short label and a verdict, so the same bytes hold turns rather than
25
+ * fragments of one.
26
+ *
27
+ * It lives beside `known-sessions.json` under `stateDir()` rather than in
28
+ * `~/.zeph`, for the same reason that file does: this is derived session state
29
+ * that expires, not configuration or keys.
30
+ *
31
+ * Everything here is best-effort. A ring that cannot be written costs the
32
+ * scrollback, never the live send — the daemon's job is to keep talking.
33
+ */
34
+ /**
35
+ * One ring file. Trimming is by bytes and not by event count on purpose: a
36
+ * prose event carries up to `MAX_EVENT_TEXT_CHARS` (5000) while a tool event's
37
+ * label is clamped at `MAX_EVENT_FIELD_CHARS` (512), so a count would bound two
38
+ * things an order of magnitude apart.
39
+ */
40
+ exports.MAX_TURN_RING_BYTES = 256 * 1024;
41
+ /** Sessions with a ring. Past this the oldest by mtime are dropped. */
42
+ exports.MAX_TURN_RING_FILES = 20;
43
+ /** A week unwatched. Scrollback older than that is history, not context. */
44
+ exports.TURN_RING_TTL_MS = 7 * 24 * 60 * 60 * 1000;
45
+ const turnRingDir = () => (0, path_1.join)((0, gate_js_1.stateDir)(), 'turns');
46
+ exports.turnRingDir = turnRingDir;
47
+ /**
48
+ * The session name is chosen by whoever sent the watch request, so it never
49
+ * reaches the filesystem — a hash does. Same move as `hashListenerId`, at twice
50
+ * the width: 64 bits, because these names collide within one machine's
51
+ * directory rather than identifying one device.
52
+ */
53
+ const turnRingPath = (sessionName) => (0, path_1.join)((0, exports.turnRingDir)(), `${(0, crypto_1.createHash)('sha256').update(sessionName).digest('hex').slice(0, 16)}.jsonl`);
54
+ exports.turnRingPath = turnRingPath;
55
+ const isExpired = (path, now) => {
56
+ try {
57
+ return now - (0, fs_1.statSync)(path).mtimeMs > exports.TURN_RING_TTL_MS;
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ };
63
+ const removeQuietly = (path) => {
64
+ try {
65
+ (0, fs_1.unlinkSync)(path);
66
+ }
67
+ catch {
68
+ /* already gone, or not ours to delete */
69
+ }
70
+ };
71
+ /**
72
+ * How far under the cap a trim cuts.
73
+ *
74
+ * Trimming exactly to the cap would leave the file one event below it, so the
75
+ * next append crosses again and rewrites the whole thing — a full read, split
76
+ * and rename per event for the rest of the session. Cutting deeper buys roughly
77
+ * a quarter of the ring's worth of appends between rewrites.
78
+ */
79
+ const TRIM_TARGET_BYTES = Math.floor(exports.MAX_TURN_RING_BYTES * 0.75);
80
+ /** Keep the newest lines that fit under `MAX_TURN_RING_BYTES`. False when the cap still stands broken. */
81
+ const trimToCap = (path) => {
82
+ let raw;
83
+ try {
84
+ if ((0, fs_1.statSync)(path).size <= exports.MAX_TURN_RING_BYTES)
85
+ return true;
86
+ raw = (0, fs_1.readFileSync)(path, 'utf-8');
87
+ }
88
+ catch {
89
+ return false;
90
+ }
91
+ const lines = raw.split('\n').filter((line) => line.length > 0);
92
+ let bytes = 0;
93
+ let start = lines.length;
94
+ // Newest first, until the next line would not fit.
95
+ while (start > 0) {
96
+ const size = Buffer.byteLength(lines[start - 1], 'utf-8') + 1;
97
+ if (bytes + size > TRIM_TARGET_BYTES)
98
+ break;
99
+ bytes += size;
100
+ start--;
101
+ }
102
+ // A single line longer than the whole cap takes nothing, which would empty
103
+ // the ring — and empty it again on every later append. The newest line
104
+ // always survives; the one after it trims this one away in turn.
105
+ start = Math.min(start, lines.length - 1);
106
+ const kept = lines
107
+ .slice(start)
108
+ .map((line) => `${line}\n`)
109
+ .join('');
110
+ // Through a temp file, unlike the append: a kill mid-append costs the last
111
+ // line, a kill mid-rewrite would cost the whole ring.
112
+ // Random name, exclusive create: a predictable `${path}.tmp` is a file
113
+ // another process on this machine can pre-create as a symlink and have this
114
+ // write follow. `wx` fails instead of following one.
115
+ const tmp = `${path}.${(0, crypto_1.randomBytes)(6).toString('hex')}.tmp`;
116
+ try {
117
+ (0, fs_1.writeFileSync)(tmp, kept, { mode: 0o600, flag: 'wx' });
118
+ (0, fs_1.renameSync)(tmp, path);
119
+ return true;
120
+ }
121
+ catch {
122
+ removeQuietly(tmp);
123
+ // The append landed but the cap did not. Saying the write succeeded
124
+ // would let the file grow past its bound with nothing ever noticing.
125
+ return false;
126
+ }
127
+ };
128
+ /**
129
+ * Record events this watch just sent. Call it with the same array that went out
130
+ * — the ring's whole meaning is "what the viewer already received", and a replay
131
+ * is that array sent again.
132
+ */
133
+ const appendTurnRing = (sessionName, events) => {
134
+ if (!events.length)
135
+ return true;
136
+ let path;
137
+ try {
138
+ // Inside the guard: the name arrives off the wire, and a non-string one
139
+ // makes `createHash().update()` throw where "best-effort" is the whole
140
+ // contract — a ring that cannot be written must never take a tick down.
141
+ path = (0, exports.turnRingPath)(sessionName);
142
+ (0, fs_1.mkdirSync)((0, exports.turnRingDir)(), { recursive: true, mode: 0o700 });
143
+ (0, fs_1.appendFileSync)(path, events.map((event) => `${JSON.stringify(event)}\n`).join(''), { mode: 0o600 });
144
+ // `mode` on mkdir/append applies only when the entry is created, so a
145
+ // directory or file that already exists keeps whatever it had — a
146
+ // pre-created 0755 `turns/` would never become 0700 on its own. The
147
+ // modes are the only thing standing between another local account and a
148
+ // week of tool targets, prompts and URLs, so they are set every time
149
+ // (`config.ts:84` chmods its own file for the same reason).
150
+ (0, fs_1.chmodSync)((0, exports.turnRingDir)(), 0o700);
151
+ (0, fs_1.chmodSync)(path, 0o600);
152
+ }
153
+ catch {
154
+ // The caller says so once per session rather than per tick: a full disk
155
+ // costs the scrollback, and silence about it costs the diagnosis.
156
+ return false;
157
+ }
158
+ return trimToCap(path);
159
+ };
160
+ exports.appendTurnRing = appendTurnRing;
161
+ /**
162
+ * What this session's viewers have already been sent, oldest first.
163
+ *
164
+ * An expired ring is deleted here rather than merely ignored: returning an empty
165
+ * array and leaving the file would let the next append land on top of pre-expiry
166
+ * content, and the TTL would bound nothing.
167
+ */
168
+ const readTurnRing = (sessionName, now = Date.now()) => {
169
+ let raw;
170
+ let path;
171
+ try {
172
+ // Same reason as the append: the name is wire input, and hashing a
173
+ // non-string throws.
174
+ path = (0, exports.turnRingPath)(sessionName);
175
+ if (isExpired(path, now)) {
176
+ removeQuietly(path);
177
+ return [];
178
+ }
179
+ raw = (0, fs_1.readFileSync)(path, 'utf-8');
180
+ }
181
+ catch {
182
+ return [];
183
+ }
184
+ const events = [];
185
+ for (const line of raw.split('\n')) {
186
+ if (!line)
187
+ continue;
188
+ try {
189
+ const parsed = JSON.parse(line);
190
+ // `null`, `5`, `[]` and `{}` are all valid JSON. Without a shape
191
+ // check a hand-edited or planted ring hands slice 02 a non-event to
192
+ // seal and send. `kind` is what every branch downstream switches on,
193
+ // so that is what makes a line an event — the same row-level filter
194
+ // `session-registry.ts:52-62` applies to its own file.
195
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
196
+ continue;
197
+ if (typeof parsed.kind !== 'string')
198
+ continue;
199
+ events.push(parsed);
200
+ }
201
+ catch {
202
+ // A line the daemon was killed halfway through: skip it, keep the
203
+ // rest — again the transcript reader's rule.
204
+ }
205
+ }
206
+ return events;
207
+ };
208
+ exports.readTurnRing = readTurnRing;
209
+ /** Drop expired rings, then the oldest ones past `MAX_TURN_RING_FILES`. */
210
+ const sweepTurnRings = (now = Date.now()) => {
211
+ const dir = (0, exports.turnRingDir)();
212
+ let names;
213
+ try {
214
+ names = (0, fs_1.readdirSync)(dir);
215
+ }
216
+ catch {
217
+ return;
218
+ }
219
+ const live = [];
220
+ for (const name of names) {
221
+ const path = (0, path_1.join)(dir, name);
222
+ // A kill between the trim's write and its rename strands a temp file.
223
+ // Nothing else would ever remove it, and left in place it counts
224
+ // against MAX_TURN_RING_FILES and evicts a live ring early. Matching the
225
+ // ring's own name shape rather than just the extension also means a
226
+ // stray file dropped in here is cleaned up rather than counted.
227
+ if (!/^[0-9a-f]{16}\.jsonl$/.test(name)) {
228
+ removeQuietly(path);
229
+ continue;
230
+ }
231
+ let mtimeMs;
232
+ try {
233
+ mtimeMs = (0, fs_1.statSync)(path).mtimeMs;
234
+ }
235
+ catch {
236
+ continue;
237
+ }
238
+ if (now - mtimeMs > exports.TURN_RING_TTL_MS)
239
+ removeQuietly(path);
240
+ else
241
+ live.push({ path, mtimeMs });
242
+ }
243
+ if (live.length <= exports.MAX_TURN_RING_FILES)
244
+ return;
245
+ live.sort((a, b) => a.mtimeMs - b.mtimeMs);
246
+ for (const stale of live.slice(0, live.length - exports.MAX_TURN_RING_FILES))
247
+ removeQuietly(stale.path);
248
+ };
249
+ exports.sweepTurnRings = sweepTurnRings;
250
+ /**
251
+ * Forget one session's scrollback outright — the machine was told to forget the
252
+ * session. False when the file is still there, which the caller has to say out
253
+ * loud: answering "forgotten" while a week of prompts and tool targets sits on
254
+ * disk is the one wrong answer here.
255
+ */
256
+ const removeTurnRing = (sessionName) => {
257
+ let path;
258
+ try {
259
+ path = (0, exports.turnRingPath)(sessionName);
260
+ }
261
+ catch {
262
+ return true; // a name that cannot even be hashed has no ring
263
+ }
264
+ try {
265
+ (0, fs_1.unlinkSync)(path);
266
+ return true;
267
+ }
268
+ catch (err) {
269
+ // Already gone is the outcome the caller asked for. Checking existence
270
+ // first and then unlinking would call a file that vanished in between a
271
+ // failure, and answer "your scrollback is still there" about nothing.
272
+ return err?.code === 'ENOENT';
273
+ }
274
+ };
275
+ exports.removeTurnRing = removeTurnRing;
276
+ exports.diskTurnRing = {
277
+ read: (sessionName) => (0, exports.readTurnRing)(sessionName),
278
+ append: (sessionName, events) => (0, exports.appendTurnRing)(sessionName, events),
279
+ sweep: () => (0, exports.sweepTurnRings)(),
280
+ };
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Live agent-chat timeline: tail this machine's Claude Code transcripts and push
3
+ * the small events off them over the relay the daemon already holds open.
4
+ *
5
+ * Sibling of the tmux mirror in `listener.ts`, deliberately not the same thing.
6
+ * The mirror sends a picture of a terminal; this sends what the agent *did*, so
7
+ * a phone can read a session the way the Claude app reads one — without the
8
+ * viewer having to parse ANSI, and without any of it becoming a push (no quota,
9
+ * no notification).
10
+ *
11
+ * Registry, lifecycle and framing mirror `activeStreams`/`stopStream`, because
12
+ * a second shape for the same job is a second set of leaks to find. What differs
13
+ * is the source: a file that grows, not a pane that repaints. `transcript-tail`
14
+ * owns that difference and stays pure; this file owns the socket and the clock.
15
+ *
16
+ * Every dependency that touches the world arrives through `TurnWatchDeps`, so
17
+ * the tests drive real ticks with a fake transcript and no WebSocket at all.
18
+ */
19
+ import { type TurnEvent } from './transcript-tail.js';
20
+ import type { TurnRing } from './turn-ring.js';
21
+ /**
22
+ * Watchers this daemon will run at once — the same ceiling, for the same reason,
23
+ * as `MAX_CONCURRENT_STREAMS`. One machine can host a dozen tmux sessions, and
24
+ * a viewer that opened them all would otherwise have every one polling forever.
25
+ */
26
+ export declare const MAX_TURN_WATCHERS = 3;
27
+ /**
28
+ * Poll cadence. The plan's user-visible bar is "a tool call shows up within 2s";
29
+ * at 500 ms the read itself is never the reason it misses. Slower than the
30
+ * mirror's 400 ms on purpose — this loop answers "what happened", not "what does
31
+ * the screen look like", and nothing here is animated.
32
+ */
33
+ export declare const TURN_POLL_INTERVAL_MS = 500;
34
+ /**
35
+ * How long a watch survives without a renew.
36
+ *
37
+ * `watch.stop` is best-effort by construction: a swiped-away native sheet, a
38
+ * killed tab, or a dropped socket destroys the viewer before it can say
39
+ * anything. The lease — not the stop message — is what guarantees this daemon
40
+ * stops reading. Matches the terminal stream's `STREAM_SUB_TTL_SECONDS`.
41
+ */
42
+ export declare const TURN_LEASE_MS = 60000;
43
+ /**
44
+ * How often a live watch re-asks which transcript its session is writing.
45
+ *
46
+ * `/clear` and a compaction start a new session file under a new name; the old
47
+ * one simply stops growing, so a watcher pinned to the path it resolved at
48
+ * `start` goes quiet forever and looks exactly like an idle agent. Nothing in
49
+ * the file itself can signal this — the answer lives in the session registry —
50
+ * so it is asked for on a cadence rather than discovered.
51
+ *
52
+ * This is not a cheap question, and the number is chosen against its real cost:
53
+ * `resolveTranscript` runs `readPaneInfo`, an uncached blocking
54
+ * `spawnSync('tmux', …)` (`listener.ts`), and the pid-record memo behind it
55
+ * expires every 4s (`remote-agents.ts` SNAPSHOT_TTL_MS), so a recheck is one
56
+ * tmux spawn plus a real directory read — not a memo hit. At 10s per watcher and
57
+ * at most three watchers that stays under what the session report already spends
58
+ * on its own (`SESSION_REPORT_INTERVAL_MS` = 5s, one sweep for the whole
59
+ * machine), while keeping how long a cleared session stays dark to one interval.
60
+ */
61
+ export declare const TRANSCRIPT_RECHECK_MS = 10000;
62
+ /**
63
+ * Consecutive seal failures before the watch gives up and says so.
64
+ *
65
+ * Dropping a batch that will not seal is right; dropping every batch forever is
66
+ * an empty timeline the viewer cannot tell from an idle session. The mirror
67
+ * draws the same line with `STREAM_MAX_ENCRYPT_FAILURES`: fail closed, then stop
68
+ * and send an error the other side can render.
69
+ */
70
+ export declare const MAX_TURN_SEAL_FAILURES = 3;
71
+ /**
72
+ * Ceiling on one replay, in events.
73
+ *
74
+ * The viewer keeps a bounded window of live turns, and a replay that filled it
75
+ * on its own would push the turn actually in flight out of the very screen the
76
+ * replay exists to fill. Below the web cap on purpose, so the live tail still
77
+ * has room after a full replay.
78
+ */
79
+ export declare const MAX_REPLAY_EVENTS = 400;
80
+ /**
81
+ * Plaintext bytes per frame. Ephemeral frames ride API Gateway's 32KB WebSocket
82
+ * limit, and sealing base64-expands what goes in it — so this is deliberately
83
+ * below `SCREEN_PEEK_MAX_BYTES` (24KB, `listener.ts`), which bounds frames that
84
+ * are never sealed.
85
+ */
86
+ export declare const MAX_TURN_FRAME_BYTES: number;
87
+ /** Wire shape of one batch of timeline events. */
88
+ export type TurnDeltaFrame = {
89
+ subtype: 'agent.turn.delta';
90
+ sessionName: string;
91
+ /** Incarnation of this watch. See `AgentTurnDeltaFrame` (zeph `libs/feed-ui`)
92
+ * for why the pair exists; the mirror stamps frames the same way
93
+ * (`StreamFramePayload.epoch`). */
94
+ epoch: number;
95
+ seq: number;
96
+ /** Plaintext batch — present only when the subscriber supplied no key. */
97
+ events?: TurnEvent[];
98
+ /** Sealed batch — the same array, encrypted for the subscriber. */
99
+ encrypted?: unknown;
100
+ };
101
+ /** What a viewer sends to start, keep, or end a watch. */
102
+ export interface TurnWatchControl {
103
+ subtype?: string;
104
+ targetDeviceId?: string;
105
+ sessionName?: string;
106
+ /**
107
+ * The viewer's device public key. Present when that device can open what it
108
+ * asks to be sealed; absent on a device with no keypair, which then reads
109
+ * the same events in the clear — the convention `useAgentDiff` and
110
+ * `TerminalStreamView` already follow on the web side. Encryption is a paid
111
+ * feature, so refusing to serve an unsealed viewer would quietly make the
112
+ * whole timeline paid too.
113
+ */
114
+ subscriberPublicKey?: string;
115
+ }
116
+ export type SendTurnFrame = (data: Record<string, unknown>) => void;
117
+ export interface TurnWatchDeps {
118
+ /** This machine's listener device id — control messages that name another are not ours. */
119
+ deviceId: () => string;
120
+ /** tmux session name → transcript file, or null when this session has none (a non-Claude agent). */
121
+ resolveTranscript: (sessionName: string) => string | null;
122
+ /** Whether tmux still holds this session. A watch outlives its pane otherwise. */
123
+ sessionExists: (sessionName: string) => boolean;
124
+ /**
125
+ * Load-or-create this device's keypair. Rejecting means this daemon cannot
126
+ * seal at all, which a viewer that asked for a seal must be told about
127
+ * rather than discovering three dropped batches later.
128
+ */
129
+ initCrypto: () => Promise<void>;
130
+ /** Seal a batch for the subscriber. Rejecting drops the batch; it never falls back to plaintext. */
131
+ seal: (plaintext: string, subscriberPublicKey: string) => Promise<unknown>;
132
+ log: (message: string) => void;
133
+ /** Where a watch's events are kept so a later watch can replay them. */
134
+ ring: TurnRing;
135
+ now?: () => number;
136
+ }
137
+ /**
138
+ * A registry of transcript watchers plus the control handler that drives it.
139
+ *
140
+ * A factory rather than module state so a test can hold its own, and so two of
141
+ * them never share a clock or a socket by accident.
142
+ */
143
+ export declare const createTurnWatchers: (deps: TurnWatchDeps) => {
144
+ handle: (req: TurnWatchControl, send: SendTurnFrame) => boolean;
145
+ stop: (sessionName: string, reason: string) => void;
146
+ stopAll: (reason: string) => void;
147
+ tick: (sessionName: string) => Promise<void>;
148
+ size: () => number;
149
+ };
150
+ export type TurnWatchers = ReturnType<typeof createTurnWatchers>;
151
+ //# sourceMappingURL=turn-watch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"turn-watch.d.ts","sourceRoot":"","sources":["../src/turn-watch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAKH,KAAK,SAAS,EACjB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE/C;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,IAAI,CAAC;AAEnC;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,MAAM,CAAC;AAEzC;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa,QAAS,CAAC;AAEpC;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,qBAAqB,QAAS,CAAC;AAE5C;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,IAAI,CAAC;AAExC;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,MAAM,CAAC;AAErC;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,QAAY,CAAC;AAE9C,kDAAkD;AAClD,MAAM,MAAM,cAAc,GAAG;IACzB,OAAO,EAAE,kBAAkB,CAAC;IAC5B,WAAW,EAAE,MAAM,CAAC;IACpB;;wCAEoC;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,0EAA0E;IAC1E,MAAM,CAAC,EAAE,SAAS,EAAE,CAAC;IACrB,mEAAmE;IACnE,SAAS,CAAC,EAAE,OAAO,CAAC;CACvB,CAAC;AAEF,0DAA0D;AAC1D,MAAM,WAAW,gBAAgB;IAC7B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;OAOG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAChC;AAED,MAAM,MAAM,aAAa,GAAG,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;AAEpE,MAAM,WAAW,aAAa;IAC1B,2FAA2F;IAC3F,QAAQ,EAAE,MAAM,MAAM,CAAC;IACvB,oGAAoG;IACpG,iBAAiB,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;IAC1D,kFAAkF;IAClF,aAAa,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC;IAChD;;;;OAIG;IACH,UAAU,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAChC,oGAAoG;IACpG,IAAI,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,mBAAmB,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC3E,GAAG,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,wEAAwE;IACxE,IAAI,EAAE,QAAQ,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACtB;AAuDD;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,GAAI,MAAM,aAAa;kBA2N7B,gBAAgB,QAAQ,aAAa,KAAG,OAAO;wBAvNzC,MAAM,UAAU,MAAM,KAAG,IAAI;sBAe/B,MAAM,KAAG,IAAI;wBASL,MAAM,KAAG,OAAO,CAAC,IAAI,CAAC;;CAoX1D,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC"}