@idl3/claude-control 0.1.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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -0
  3. package/bin/cli.js +68 -0
  4. package/bin/install-service.sh +107 -0
  5. package/bin/self-update.sh +43 -0
  6. package/bin/uninstall-service.sh +22 -0
  7. package/lib/answer.js +64 -0
  8. package/lib/auth.js +81 -0
  9. package/lib/config.js +118 -0
  10. package/lib/push.js +153 -0
  11. package/lib/resources.js +137 -0
  12. package/lib/sessions.js +529 -0
  13. package/lib/terminal.js +278 -0
  14. package/lib/tmux.js +462 -0
  15. package/lib/transcript.js +451 -0
  16. package/lib/tui.js +50 -0
  17. package/lib/uploads.js +42 -0
  18. package/lib/version.js +73 -0
  19. package/package.json +49 -0
  20. package/public/app.js +756 -0
  21. package/public/index.html +120 -0
  22. package/public/styles.css +848 -0
  23. package/server.js +910 -0
  24. package/web/README.md +66 -0
  25. package/web/dist/apple-touch-icon.png +0 -0
  26. package/web/dist/assets/bash-I8pq0VWm.js +1 -0
  27. package/web/dist/assets/core-BYJcZW10.js +3 -0
  28. package/web/dist/assets/css-DazXZka4.js +1 -0
  29. package/web/dist/assets/diff-DiTmLxSS.js +1 -0
  30. package/web/dist/assets/index-Bb7gXgl-.css +1 -0
  31. package/web/dist/assets/index-wrjqfzbL.js +77 -0
  32. package/web/dist/assets/javascript-BKRaQes9.js +1 -0
  33. package/web/dist/assets/json-DIYVocXf.js +1 -0
  34. package/web/dist/assets/markdown-BrP960CR.js +1 -0
  35. package/web/dist/assets/python-sE43i1Pi.js +1 -0
  36. package/web/dist/assets/typescript-C2FFdlUC.js +1 -0
  37. package/web/dist/assets/xml-BXBhIUeX.js +1 -0
  38. package/web/dist/icon-192.png +0 -0
  39. package/web/dist/icon-512.png +0 -0
  40. package/web/dist/index.html +25 -0
  41. package/web/dist/manifest.webmanifest +25 -0
  42. package/web/dist/sw.js +57 -0
@@ -0,0 +1,529 @@
1
+ /**
2
+ * lib/sessions.js — SessionRegistry
3
+ *
4
+ * Periodically reconciles tmux windows with Claude transcript files found under
5
+ * projectsRoot. Emits 'change' when the session list changes. Never reads a
6
+ * transcript file in full — only the tail (≤64 KB) of the newest *.jsonl per
7
+ * project directory.
8
+ */
9
+
10
+ import { EventEmitter } from 'node:events';
11
+ import fs from 'node:fs/promises';
12
+ import path from 'node:path';
13
+
14
+ import { parseTuiStatus, prettyModel } from './tui.js';
15
+
16
+ // A pane is a Claude Code session when its process title is the Claude version
17
+ // (e.g. "2.1.162") — shells report zsh/bash/etc. A linked transcript also counts.
18
+ function isClaudeCmd(cmd) {
19
+ return /^\d+\.\d+(\.\d+)?$/.test(String(cmd || '').trim());
20
+ }
21
+
22
+ const TAIL_BYTES = 64 * 1024; // 64 KB max tail read
23
+ const REFRESH_INTERVAL_MS = 4000;
24
+ const CTX_POLL_INTERVAL_MS = 12000; // TUI ctx%/model capture — slower than refresh
25
+
26
+ /**
27
+ * Encode an absolute cwd the way Claude Code names its transcript project
28
+ * directories: every '/' and '.' becomes '-'. This is derived from the cwd the
29
+ * session was LAUNCHED in (== the tmux pane's current path), so it is immune to
30
+ * a mid-session `cd` that would change the cwd recorded inside the transcript.
31
+ *
32
+ * @param {string} cwd
33
+ * @returns {string}
34
+ */
35
+ export function encodeCwd(cwd) {
36
+ return cwd.replace(/[/.]/g, '-');
37
+ }
38
+
39
+ /**
40
+ * Is the cwd recorded inside a transcript consistent with a tmux window's cwd?
41
+ * True when unknown (null), equal, or a descendant directory (the session
42
+ * launched in winCwd and later cd'd deeper). Guards against encodeCwd collisions.
43
+ *
44
+ * @param {string|null} recCwd cwd recorded in the transcript tail
45
+ * @param {string} winCwd tmux pane current path
46
+ * @returns {boolean}
47
+ */
48
+ export function isCwdConsistent(recCwd, winCwd) {
49
+ if (!recCwd) return true;
50
+ return recCwd === winCwd || recCwd.startsWith(winCwd.replace(/\/$/, '') + '/');
51
+ }
52
+
53
+ const PENDING_QUESTION_MAX = 140; // truncate the surfaced question text
54
+
55
+ /**
56
+ * Walk a set of JSONL tail lines and decide whether an AskUserQuestion is still
57
+ * OPEN — i.e. an assistant `tool_use` block named "AskUserQuestion" exists whose
58
+ * id has NO matching `tool_result` (tool_use_id) later in the tail. Pure and
59
+ * unit-testable in isolation (see test/push-pending.test.js).
60
+ *
61
+ * @param {string[]} lines Complete JSONL lines (partial first line tolerated).
62
+ * @returns {{ transcriptPending: boolean, pendingToolUseId: string|null, pendingQuestion: string|null }}
63
+ */
64
+ export function detectTranscriptPending(lines) {
65
+ /** @type {Map<string, string|null>} open AskUserQuestion id -> first question text */
66
+ const open = new Map();
67
+ const resolved = new Set();
68
+
69
+ for (const raw of lines) {
70
+ const line = String(raw || '').trim();
71
+ if (!line) continue;
72
+ let rec;
73
+ try { rec = JSON.parse(line); } catch { continue; }
74
+ if (!rec || typeof rec !== 'object') continue;
75
+
76
+ const content = rec.message?.content;
77
+ if (!Array.isArray(content)) continue;
78
+
79
+ for (const block of content) {
80
+ if (!block || typeof block !== 'object') continue;
81
+ if (
82
+ rec.type === 'assistant' &&
83
+ block.type === 'tool_use' &&
84
+ block.name === 'AskUserQuestion' &&
85
+ typeof block.id === 'string'
86
+ ) {
87
+ const q = block.input?.questions?.[0]?.question;
88
+ open.set(block.id, typeof q === 'string' ? q : null);
89
+ } else if (
90
+ rec.type === 'user' &&
91
+ block.type === 'tool_result' &&
92
+ typeof block.tool_use_id === 'string'
93
+ ) {
94
+ resolved.add(block.tool_use_id);
95
+ }
96
+ }
97
+ }
98
+
99
+ // Newest still-open AskUserQuestion (Map preserves insertion order).
100
+ let pendingToolUseId = null;
101
+ let pendingQuestion = null;
102
+ for (const [id, question] of open) {
103
+ if (resolved.has(id)) continue;
104
+ pendingToolUseId = id;
105
+ pendingQuestion = question;
106
+ }
107
+
108
+ if (pendingQuestion && pendingQuestion.length > PENDING_QUESTION_MAX) {
109
+ pendingQuestion = pendingQuestion.slice(0, PENDING_QUESTION_MAX);
110
+ }
111
+
112
+ return {
113
+ transcriptPending: pendingToolUseId !== null,
114
+ pendingToolUseId,
115
+ pendingQuestion,
116
+ };
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Tiny tail-read helper
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /**
124
+ * Read the last `maxBytes` of a file and return its contents as a Buffer.
125
+ * Never throws — returns null on any error.
126
+ *
127
+ * @param {string} filePath
128
+ * @param {number} maxBytes
129
+ * @returns {Promise<Buffer|null>}
130
+ */
131
+ async function readTail(filePath, maxBytes) {
132
+ let fh;
133
+ try {
134
+ fh = await fs.open(filePath, 'r');
135
+ const stat = await fh.stat();
136
+ const size = stat.size;
137
+ if (size === 0) return Buffer.alloc(0);
138
+ const readSize = Math.min(size, maxBytes);
139
+ const offset = size - readSize;
140
+ const buf = Buffer.allocUnsafe(readSize);
141
+ const { bytesRead } = await fh.read(buf, 0, readSize, offset);
142
+ return buf.subarray(0, bytesRead);
143
+ } catch {
144
+ return null;
145
+ } finally {
146
+ if (fh) await fh.close().catch(() => {});
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Parse the tail buffer of a JSONL file and return the last record that has a
152
+ * truthy `.cwd` field, plus basic metadata.
153
+ *
154
+ * @param {string} filePath Absolute path of the .jsonl file
155
+ * @param {number} mtime mtime (ms since epoch) of the file
156
+ * @returns {Promise<{cwd:string, sessionId:string|null, lastActivity:string|null, transcriptPath:string, mtime:number}|null>}
157
+ */
158
+ async function extractTailRecord(filePath, mtime) {
159
+ const buf = await readTail(filePath, TAIL_BYTES);
160
+ if (!buf) return null;
161
+
162
+ const text = buf.toString('utf8');
163
+ // Split on newlines; the first segment may be a partial line (the tail read
164
+ // can start part-way through a line), so we never trust it — we only walk
165
+ // complete lines from the end.
166
+ const lines = text.split('\n');
167
+
168
+ const base = {
169
+ cwd: null,
170
+ sessionId: null,
171
+ lastActivity: null,
172
+ model: null,
173
+ aiTitle: null,
174
+ customTitle: null,
175
+ transcriptPath: filePath,
176
+ mtime,
177
+ transcriptPending: false,
178
+ pendingToolUseId: null,
179
+ pendingQuestion: null,
180
+ };
181
+
182
+ // Transcript-derived pending: detect an AskUserQuestion that is open in the
183
+ // tail (no matching tool_result) even when no tailer is subscribed. Notifies
184
+ // for ANY session, not just the one a client is watching.
185
+ const pending = detectTranscriptPending(lines);
186
+ base.transcriptPending = pending.transcriptPending;
187
+ base.pendingToolUseId = pending.pendingToolUseId;
188
+ base.pendingQuestion = pending.pendingQuestion;
189
+
190
+ // Walk from end collecting the newest cwd/sessionId/timestamp/model/title.
191
+ // ai-title is re-emitted throughout the file so the tail usually carries it;
192
+ // custom-title (a user /rename) is written when renamed, so it appears late.
193
+ for (let i = lines.length - 1; i >= 0; i--) {
194
+ const line = lines[i].trim();
195
+ if (!line) continue;
196
+ let rec;
197
+ try { rec = JSON.parse(line); } catch { continue; }
198
+ if (!rec || typeof rec !== 'object') continue;
199
+ if (base.lastActivity === null && typeof rec.timestamp === 'string') base.lastActivity = rec.timestamp;
200
+ if (base.sessionId === null && typeof rec.sessionId === 'string') base.sessionId = rec.sessionId;
201
+ if (base.customTitle === null && rec.type === 'custom-title' && rec.customTitle) base.customTitle = rec.customTitle;
202
+ if (base.aiTitle === null && rec.type === 'ai-title' && rec.aiTitle) base.aiTitle = rec.aiTitle;
203
+ if (base.model === null && rec.type === 'assistant' && typeof rec.message?.model === 'string') base.model = rec.message.model;
204
+ if (base.cwd === null && typeof rec.cwd === 'string' && rec.cwd) base.cwd = rec.cwd;
205
+ if (base.cwd && base.sessionId && base.model && (base.customTitle || base.aiTitle)) {
206
+ break; // everything found
207
+ }
208
+ }
209
+ return base;
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // findNewestJsonl — returns { path, mtime } or null
214
+ // ---------------------------------------------------------------------------
215
+
216
+ /**
217
+ * Given a directory, find the *.jsonl file with the newest mtime.
218
+ *
219
+ * @param {string} dir
220
+ * @returns {Promise<{filePath:string, mtime:number}|null>}
221
+ */
222
+ async function findNewestJsonl(dir) {
223
+ let entries;
224
+ try {
225
+ entries = await fs.readdir(dir);
226
+ } catch {
227
+ return null;
228
+ }
229
+
230
+ let newest = null;
231
+
232
+ await Promise.all(
233
+ entries
234
+ .filter((e) => e.endsWith('.jsonl'))
235
+ .map(async (e) => {
236
+ const full = path.join(dir, e);
237
+ let st;
238
+ try { st = await fs.stat(full); } catch { return; }
239
+ const mtime = st.mtimeMs;
240
+ if (!newest || mtime > newest.mtime) {
241
+ newest = { filePath: full, mtime };
242
+ }
243
+ }),
244
+ );
245
+
246
+ return newest;
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // SessionRegistry
251
+ // ---------------------------------------------------------------------------
252
+
253
+ export class SessionRegistry extends EventEmitter {
254
+ /**
255
+ * @param {{ projectsRoot: string, tmux: object, debounceMs?: number }} opts
256
+ */
257
+ constructor({ projectsRoot, tmux, debounceMs = 1000 } = {}) {
258
+ super();
259
+ this._projectsRoot = projectsRoot;
260
+ this._tmux = tmux;
261
+ this._debounceMs = debounceMs;
262
+
263
+ /** @type {Session[]} */
264
+ this._sessions = [];
265
+ /** @type {string|null} — last JSON snapshot for change detection */
266
+ this._lastEmitted = null;
267
+ /** @type {Map<string, boolean>} id -> pending flag */
268
+ this._pendingMap = new Map();
269
+ /** @type {Map<string, {ctxPct:number|null, model:string|null}>} windowId -> TUI status */
270
+ this._ctxMap = new Map();
271
+ /** @type {ReturnType<setInterval>|null} */
272
+ this._interval = null;
273
+ /** @type {ReturnType<setInterval>|null} */
274
+ this._ctxInterval = null;
275
+ }
276
+
277
+ // -------------------------------------------------------------------------
278
+ // Public API
279
+ // -------------------------------------------------------------------------
280
+
281
+ /** @returns {Session[]} */
282
+ getSessions() {
283
+ return this._sessions;
284
+ }
285
+
286
+ /**
287
+ * Set the pending flag for a session (called by server when tailer fires
288
+ * 'pending'). Emits 'change' if the flag actually flipped.
289
+ *
290
+ * @param {string} id
291
+ * @param {boolean} pending
292
+ */
293
+ setPending(id, pending) {
294
+ const session = this._sessions.find((s) => s.id === id);
295
+ if (!session) return;
296
+ const was = session.pending;
297
+ session.pending = !!pending;
298
+ this._pendingMap.set(id, !!pending);
299
+ if (was !== session.pending) {
300
+ this._maybeEmit();
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Rescan tmux windows and project directories. Returns the new session list.
306
+ *
307
+ * @returns {Promise<Session[]>}
308
+ */
309
+ async refresh() {
310
+ const [allWindows, transcriptIndex] = await Promise.all([
311
+ this._listWindows(),
312
+ this._buildTranscriptIndex(),
313
+ ]);
314
+
315
+ // Grouped tmux sessions (e.g. a `_mobile` mirror of session `0`) expose the
316
+ // SAME underlying window under multiple session names — identical window_id.
317
+ // Collapse those so the UI shows each real window once (keeping the first,
318
+ // which is the primary session by tmux's list ordering).
319
+ const seenWindowIds = new Set();
320
+ const windows = allWindows.filter((w) => {
321
+ if (seenWindowIds.has(w.windowId)) return false;
322
+ seenWindowIds.add(w.windowId);
323
+ return true;
324
+ });
325
+
326
+ const sessions = windows.map((win) => {
327
+ // Primary match: directory-name encoding (survives mid-session `cd`).
328
+ // encodeCwd is lossy ('/' and '.' both -> '-'), so a byDir hit is only
329
+ // trusted when the cwd recorded inside the transcript is consistent with
330
+ // this window's cwd — equal, a descendant (the agent cd'd into a subdir),
331
+ // or absent. An unrelated sibling (e.g. my.lib vs my-lib) is rejected and
332
+ // falls through to the exact-cwd index.
333
+ const byDirHit = transcriptIndex.byDir.get(encodeCwd(win.cwd));
334
+ const transcript =
335
+ (byDirHit && isCwdConsistent(byDirHit.cwd, win.cwd) ? byDirHit : null) ??
336
+ transcriptIndex.byCwd.get(win.cwd) ??
337
+ null;
338
+ const id = win.target;
339
+ // Pending = subscribed-tailer pending (live modal) OR transcript-derived
340
+ // pending (works for ANY session, even unsubscribed ones, for push).
341
+ const pending =
342
+ (this._pendingMap.get(id) ?? false) || !!transcript?.transcriptPending;
343
+ const title = transcript?.customTitle || transcript?.aiTitle || null;
344
+ const ctx = this._ctxMap.get(win.windowId) || {};
345
+
346
+ return {
347
+ id,
348
+ sessionId: transcript?.sessionId ?? null,
349
+ // Best label: live TUI/transcript title > tmux window name > target.
350
+ name: title || win.windowName || win.target,
351
+ title,
352
+ tmuxName: win.windowName,
353
+ target: win.target,
354
+ sessionName: win.sessionName,
355
+ windowIndex: win.windowIndex,
356
+ paneIndex: win.paneIndex,
357
+ windowId: win.windowId,
358
+ active: win.active,
359
+ cwd: win.cwd,
360
+ transcriptPath: transcript?.transcriptPath ?? null,
361
+ lastActivity: transcript?.lastActivity ?? null,
362
+ pending,
363
+ pendingQuestion: transcript?.pendingQuestion ?? null,
364
+ cmd: win.cmd,
365
+ isClaude: true,
366
+ model: ctx.model || prettyModel(transcript?.model) || null,
367
+ ctxPct: ctx.ctxPct ?? null,
368
+ };
369
+ });
370
+
371
+ // Dedup transcript collisions: when multiple tmux windows share a cwd they
372
+ // all match the SAME newest transcript for that dir → the rail shows the
373
+ // same title twice and two sessions tail one file. Keep the transcript on
374
+ // the best (active, else first-seen) session; strip it from the others so
375
+ // they stay distinct, transcript-less live panes (still subscribable).
376
+ const seenTranscript = new Set();
377
+ const byPriority = [...sessions].sort((a, b) =>
378
+ a.active === b.active ? 0 : a.active ? -1 : 1,
379
+ );
380
+ for (const s of byPriority) {
381
+ if (!s.transcriptPath) continue;
382
+ if (seenTranscript.has(s.transcriptPath)) {
383
+ s.transcriptPath = null;
384
+ s.sessionId = null;
385
+ s.lastActivity = null;
386
+ s.title = null;
387
+ s.name = s.tmuxName || s.target;
388
+ // This session no longer owns the transcript, so its transcript-derived
389
+ // pending is bogus; drop it (the owning session keeps the real one).
390
+ s.pending = this._pendingMap.get(s.id) ?? false;
391
+ s.pendingQuestion = null;
392
+ } else {
393
+ seenTranscript.add(s.transcriptPath);
394
+ }
395
+ }
396
+
397
+ // Only surface Claude sessions; skip plain shell panes.
398
+ this._sessions = sessions.filter((s) => isClaudeCmd(s.cmd) || s.transcriptPath);
399
+ this._maybeEmit();
400
+ return this._sessions;
401
+ }
402
+
403
+ /**
404
+ * Capture each Claude pane's TUI status line and parse model + context %.
405
+ * Throttled (separate from the 4 s refresh) and best-effort — capture-pane is
406
+ * cheap but we keep it off the hot path per the resource doctrine.
407
+ */
408
+ async _pollCtx() {
409
+ const sessions = this._sessions;
410
+ await Promise.all(
411
+ sessions.map(async (s) => {
412
+ if (!this._tmux.isValidTarget(s.target)) return;
413
+ try {
414
+ const cap = await this._tmux.capturePane(s.target, 8);
415
+ const { ctxPct, model } = parseTuiStatus(cap);
416
+ this._ctxMap.set(s.windowId, { ctxPct, model });
417
+ // Merge into the live session object without a full rebuild.
418
+ if (ctxPct !== null) s.ctxPct = ctxPct;
419
+ if (model) s.model = model;
420
+ } catch {
421
+ // pane gone / capture failed — leave previous values
422
+ }
423
+ }),
424
+ );
425
+ this._maybeEmit();
426
+ }
427
+
428
+ /** Start periodic refresh (every 4 s) + a slower ctx poll, and fire both once. */
429
+ start() {
430
+ this.refresh().then(() => this._pollCtx()).catch(() => {});
431
+ this._interval = setInterval(() => {
432
+ this.refresh().catch(() => {});
433
+ }, REFRESH_INTERVAL_MS);
434
+ this._ctxInterval = setInterval(() => {
435
+ this._pollCtx().catch(() => {});
436
+ }, CTX_POLL_INTERVAL_MS);
437
+ if (this._interval.unref) this._interval.unref();
438
+ if (this._ctxInterval.unref) this._ctxInterval.unref();
439
+ }
440
+
441
+ /** Stop periodic refresh. */
442
+ stop() {
443
+ if (this._interval !== null) {
444
+ clearInterval(this._interval);
445
+ this._interval = null;
446
+ }
447
+ if (this._ctxInterval) {
448
+ clearInterval(this._ctxInterval);
449
+ this._ctxInterval = null;
450
+ }
451
+ }
452
+
453
+ // -------------------------------------------------------------------------
454
+ // Private helpers
455
+ // -------------------------------------------------------------------------
456
+
457
+ /**
458
+ * Safely call tmux.listWindows(), falling back to [] on any error.
459
+ *
460
+ * @returns {Promise<import('./tmux.js').Window[]>}
461
+ */
462
+ async _listWindows() {
463
+ try {
464
+ return await this._tmux.listWindows();
465
+ } catch {
466
+ return [];
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Scan all immediate subdirectories of projectsRoot. For each, find the
472
+ * newest *.jsonl and extract the last record that carries a .cwd field.
473
+ * Returns a Map keyed by cwd (keeping the newest mtime entry per cwd).
474
+ *
475
+ * @returns {Promise<Map<string, {cwd:string, sessionId:string|null, lastActivity:string|null, transcriptPath:string, mtime:number}>>}
476
+ */
477
+ async _buildTranscriptIndex() {
478
+ /** @type {{byDir: Map<string, object>, byCwd: Map<string, object>}} */
479
+ const index = { byDir: new Map(), byCwd: new Map() };
480
+
481
+ let projectEntries;
482
+ try {
483
+ const entries = await fs.readdir(this._projectsRoot, { withFileTypes: true });
484
+ projectEntries = entries
485
+ .filter((e) => e.isDirectory())
486
+ .map((e) => ({ name: e.name, dir: path.join(this._projectsRoot, e.name) }));
487
+ } catch {
488
+ return index;
489
+ }
490
+
491
+ await Promise.all(
492
+ projectEntries.map(async ({ name, dir }) => {
493
+ const newest = await findNewestJsonl(dir);
494
+ if (!newest) return;
495
+
496
+ const rec = await extractTailRecord(newest.filePath, newest.mtime);
497
+ if (!rec) return;
498
+
499
+ // Primary key: the project directory name (Claude Code's cwd encoding).
500
+ const byDirExisting = index.byDir.get(name);
501
+ if (!byDirExisting || newest.mtime > byDirExisting.mtime) {
502
+ index.byDir.set(name, rec);
503
+ }
504
+
505
+ // Secondary key: the exact cwd recorded inside the transcript, when present.
506
+ if (rec.cwd) {
507
+ const byCwdExisting = index.byCwd.get(rec.cwd);
508
+ if (!byCwdExisting || newest.mtime > byCwdExisting.mtime) {
509
+ index.byCwd.set(rec.cwd, rec);
510
+ }
511
+ }
512
+ }),
513
+ );
514
+
515
+ return index;
516
+ }
517
+
518
+ /**
519
+ * Emit 'change' only when the serialized sessions differ from the last
520
+ * emission.
521
+ */
522
+ _maybeEmit() {
523
+ const serialized = JSON.stringify(this._sessions);
524
+ if (serialized !== this._lastEmitted) {
525
+ this._lastEmitted = serialized;
526
+ this.emit('change', this._sessions);
527
+ }
528
+ }
529
+ }