@epiphytic/claudecodeui 1.2.2 → 1.3.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,513 @@
1
+ /**
2
+ * Process Cache Module
3
+ *
4
+ * Keeps Claude process information in memory with periodic updates.
5
+ * This avoids expensive process scans on every API request.
6
+ *
7
+ * Features:
8
+ * - Background refresh every 60 seconds
9
+ * - Immediate availability of cached data
10
+ * - Detection of Claude CLI processes
11
+ * - Detection of Claude tmux sessions
12
+ */
13
+
14
+ import { spawnSync } from "child_process";
15
+ import os from "os";
16
+ import { createLogger } from "./logger.js";
17
+
18
+ const log = createLogger("process-cache");
19
+
20
+ // Adaptive cache update intervals
21
+ const IDLE_CACHE_UPDATE_INTERVAL = 5 * 60 * 1000; // 5 minutes when idle
22
+ const ACTIVE_CACHE_UPDATE_INTERVAL = 60 * 1000; // 1 minute when active
23
+
24
+ // Legacy export for compatibility
25
+ const CACHE_UPDATE_INTERVAL = IDLE_CACHE_UPDATE_INTERVAL;
26
+
27
+ // Active mode flag (active when WebSocket clients are connected)
28
+ let isActiveMode = false;
29
+
30
+ // Cached data structure
31
+ const processCache = {
32
+ processes: [],
33
+ tmuxSessions: [],
34
+ detectionAvailable: true,
35
+ detectionError: null,
36
+ lastUpdated: null,
37
+ isUpdating: false,
38
+ };
39
+
40
+ // Current process ID for exclusion
41
+ const currentPid = process.pid;
42
+
43
+ // Update interval reference
44
+ let updateInterval = null;
45
+
46
+ /**
47
+ * Check if a command is an external Claude process (not spawned by us)
48
+ * @param {string} command - The process command line
49
+ * @returns {boolean}
50
+ */
51
+ function isExternalClaudeProcess(command) {
52
+ // Skip node processes (SDK internals)
53
+ if (command.startsWith("node ")) {
54
+ log.debug({ command: command.slice(0, 60) }, "Rejected: starts with node");
55
+ return false;
56
+ }
57
+
58
+ // Skip our own server
59
+ if (command.includes("claudecodeui/server")) {
60
+ log.debug(
61
+ { command: command.slice(0, 60) },
62
+ "Rejected: contains claudecodeui/server",
63
+ );
64
+ return false;
65
+ }
66
+
67
+ // Look for actual claude CLI invocations
68
+ const isExternal =
69
+ command.includes("claude ") ||
70
+ command.includes("claude-code") ||
71
+ command.match(/\/claude\s/) ||
72
+ command.endsWith("/claude");
73
+
74
+ log.debug({ command: command.slice(0, 60), isExternal }, "Process check");
75
+ return isExternal;
76
+ }
77
+
78
+ /**
79
+ * Detect Claude processes on the system
80
+ * Uses efficient batch lsof call instead of per-process calls
81
+ * @returns {{ processes: Array, detectionAvailable: boolean, error: string | null }}
82
+ */
83
+ function scanClaudeProcesses() {
84
+ const processes = [];
85
+ let detectionAvailable = true;
86
+ let error = null;
87
+
88
+ log.debug({ platform: os.platform(), currentPid }, "Scanning for processes");
89
+
90
+ if (os.platform() === "win32") {
91
+ // Windows: use wmic or tasklist
92
+ try {
93
+ const result = spawnSync(
94
+ "wmic",
95
+ [
96
+ "process",
97
+ "where",
98
+ "name like '%claude%'",
99
+ "get",
100
+ "processid,commandline",
101
+ ],
102
+ { encoding: "utf8", stdio: "pipe" },
103
+ );
104
+
105
+ if (result.status === 0) {
106
+ const lines = result.stdout.trim().split("\n").slice(1);
107
+ for (const line of lines) {
108
+ const match = line.match(/(\d+)\s*$/);
109
+ if (match) {
110
+ const pid = parseInt(match[1], 10);
111
+ if (pid !== currentPid) {
112
+ processes.push({ pid, command: line.trim(), cwd: null });
113
+ }
114
+ }
115
+ }
116
+ } else if (result.error) {
117
+ detectionAvailable = false;
118
+ error = `wmic not available: ${result.error.message}`;
119
+ }
120
+ } catch (e) {
121
+ detectionAvailable = false;
122
+ error = `Windows process detection failed: ${e.message}`;
123
+ }
124
+ } else {
125
+ // Unix: use pgrep and efficient batch lsof
126
+ try {
127
+ const pgrepCheck = spawnSync("which", ["pgrep"], {
128
+ encoding: "utf8",
129
+ stdio: "pipe",
130
+ });
131
+
132
+ if (pgrepCheck.status !== 0) {
133
+ // pgrep not available, try ps aux as fallback
134
+ log.debug("Using ps aux fallback");
135
+ try {
136
+ const psResult = spawnSync("ps", ["aux"], {
137
+ encoding: "utf8",
138
+ stdio: "pipe",
139
+ });
140
+
141
+ if (psResult.status === 0) {
142
+ const lines = psResult.stdout.split("\n");
143
+ const claudeLines = lines.filter(
144
+ (line) =>
145
+ line.toLowerCase().includes("claude") &&
146
+ !line.includes(String(currentPid)),
147
+ );
148
+ log.debug({ count: claudeLines.length }, "Found claude lines");
149
+
150
+ for (const line of claudeLines) {
151
+ const parts = line.trim().split(/\s+/);
152
+ if (parts.length >= 2) {
153
+ const pid = parseInt(parts[1], 10);
154
+ if (!isNaN(pid) && pid !== currentPid) {
155
+ const command = parts.slice(10).join(" ");
156
+ if (isExternalClaudeProcess(command)) {
157
+ processes.push({ pid, command, cwd: null });
158
+ }
159
+ }
160
+ }
161
+ }
162
+ } else {
163
+ detectionAvailable = false;
164
+ error = "Neither pgrep nor ps aux available";
165
+ }
166
+ } catch (e) {
167
+ detectionAvailable = false;
168
+ error = `Process detection failed: ${e.message}`;
169
+ }
170
+ } else {
171
+ // pgrep is available - get all PIDs at once
172
+ const pgrepResult = spawnSync("pgrep", ["-f", "claude"], {
173
+ encoding: "utf8",
174
+ stdio: "pipe",
175
+ });
176
+
177
+ if (pgrepResult.status === 0) {
178
+ const allPids = pgrepResult.stdout.trim().split("\n").filter(Boolean);
179
+ const pids = allPids
180
+ .map((p) => parseInt(p, 10))
181
+ .filter((p) => p !== currentPid);
182
+
183
+ log.debug({ foundPids: allPids.length, filteredPids: pids.length }, "pgrep found PIDs");
184
+
185
+ if (pids.length === 0) {
186
+ return { processes, detectionAvailable, error };
187
+ }
188
+
189
+ // Get command details for all PIDs in a single ps call
190
+ const psResult = spawnSync(
191
+ "ps",
192
+ ["-p", pids.join(","), "-o", "pid=,args="],
193
+ {
194
+ encoding: "utf8",
195
+ stdio: "pipe",
196
+ },
197
+ );
198
+
199
+ // Build a map of PID -> command, filtering to only external Claude processes
200
+ const pidCommands = new Map();
201
+ if (psResult.status === 0) {
202
+ const lines = psResult.stdout.trim().split("\n").filter(Boolean);
203
+ for (const line of lines) {
204
+ const match = line.match(/^\s*(\d+)\s+(.+)$/);
205
+ if (match) {
206
+ const pid = parseInt(match[1], 10);
207
+ const command = match[2];
208
+ if (isExternalClaudeProcess(command)) {
209
+ pidCommands.set(pid, command);
210
+ }
211
+ }
212
+ }
213
+ }
214
+
215
+ if (pidCommands.size === 0) {
216
+ log.debug("No external Claude processes after filtering");
217
+ return { processes, detectionAvailable, error };
218
+ }
219
+
220
+ // Get cwds for all valid PIDs in a SINGLE lsof call
221
+ // Using -d cwd to only get cwd file descriptors (much faster than full file list)
222
+ // Note: lsof -d cwd returns ALL processes; we filter by our PIDs afterward
223
+ const validPids = new Set(pidCommands.keys());
224
+ const cwdMap = new Map();
225
+
226
+ try {
227
+ const lsofResult = spawnSync(
228
+ "lsof",
229
+ ["-d", "cwd", "-Fn"],
230
+ {
231
+ encoding: "utf8",
232
+ stdio: "pipe",
233
+ timeout: 10000, // 10 second timeout
234
+ },
235
+ );
236
+
237
+ if (lsofResult.status === 0) {
238
+ // Parse lsof output: p<pid>\nfcwd\nn<path>\n format
239
+ // Filter to only the PIDs we care about
240
+ const lines = lsofResult.stdout.split("\n");
241
+ let currentPid = null;
242
+ let isCwdEntry = false;
243
+
244
+ for (const line of lines) {
245
+ if (line.startsWith("p")) {
246
+ currentPid = parseInt(line.slice(1), 10);
247
+ isCwdEntry = false;
248
+ } else if (line === "fcwd") {
249
+ isCwdEntry = true;
250
+ } else if (line.startsWith("n") && currentPid && isCwdEntry) {
251
+ // Only capture cwd for our target PIDs
252
+ if (validPids.has(currentPid)) {
253
+ const path = line.slice(1);
254
+ if (path.startsWith("/")) {
255
+ cwdMap.set(currentPid, path);
256
+ }
257
+ }
258
+ isCwdEntry = false;
259
+ }
260
+ }
261
+
262
+ log.debug(
263
+ { targetPids: validPids.size, cwdsFound: cwdMap.size },
264
+ "Batch lsof complete",
265
+ );
266
+ }
267
+ } catch {
268
+ log.debug("lsof not available for cwd detection");
269
+ }
270
+
271
+ // Build final process list
272
+ for (const [pid, command] of pidCommands) {
273
+ processes.push({
274
+ pid,
275
+ command,
276
+ cwd: cwdMap.get(pid) || null,
277
+ });
278
+ }
279
+ } else {
280
+ log.debug({ status: pgrepResult.status }, "pgrep found no processes");
281
+ }
282
+ }
283
+ } catch (e) {
284
+ detectionAvailable = false;
285
+ error = `Unix process detection failed: ${e.message}`;
286
+ }
287
+ }
288
+
289
+ return { processes, detectionAvailable, error };
290
+ }
291
+
292
+ /**
293
+ * Heuristic check if a tmux session might be running Claude
294
+ * @param {string} sessionName - The session name
295
+ * @returns {boolean}
296
+ */
297
+ function mightBeClaudeSession(sessionName) {
298
+ const claudePatterns = ["claude", "ai", "chat", "code"];
299
+ const lowerName = sessionName.toLowerCase();
300
+
301
+ for (const pattern of claudePatterns) {
302
+ if (lowerName.includes(pattern)) return true;
303
+ }
304
+
305
+ // Try to peek at the session's current command
306
+ try {
307
+ const result = spawnSync(
308
+ "tmux",
309
+ ["display-message", "-t", sessionName, "-p", "#{pane_current_command}"],
310
+ { encoding: "utf8", stdio: "pipe" },
311
+ );
312
+
313
+ if (result.status === 0) {
314
+ const currentCommand = result.stdout.trim().toLowerCase();
315
+ if (currentCommand.includes("claude")) return true;
316
+ }
317
+ } catch {
318
+ // Ignore
319
+ }
320
+
321
+ return false;
322
+ }
323
+
324
+ /**
325
+ * Detect tmux sessions that might be running Claude
326
+ * @returns {Array<{ sessionName: string, windows: number, attached: boolean }>}
327
+ */
328
+ function scanClaudeTmuxSessions() {
329
+ const sessions = [];
330
+
331
+ try {
332
+ const result = spawnSync(
333
+ "tmux",
334
+ [
335
+ "list-sessions",
336
+ "-F",
337
+ "#{session_name}:#{session_windows}:#{session_attached}",
338
+ ],
339
+ { encoding: "utf8", stdio: "pipe" },
340
+ );
341
+
342
+ if (result.status === 0) {
343
+ const lines = result.stdout.trim().split("\n").filter(Boolean);
344
+
345
+ for (const line of lines) {
346
+ const [sessionName, windows, attached] = line.split(":");
347
+
348
+ // Skip our own sessions
349
+ if (sessionName.startsWith("claudeui-")) continue;
350
+
351
+ if (mightBeClaudeSession(sessionName)) {
352
+ sessions.push({
353
+ sessionName,
354
+ windows: parseInt(windows, 10),
355
+ attached: attached === "1",
356
+ });
357
+ }
358
+ }
359
+ }
360
+ } catch {
361
+ // tmux not available or no server running
362
+ }
363
+
364
+ return sessions;
365
+ }
366
+
367
+ /**
368
+ * Update the process cache with fresh data
369
+ */
370
+ async function updateCache() {
371
+ if (processCache.isUpdating) {
372
+ log.debug("Cache update already in progress, skipping");
373
+ return;
374
+ }
375
+
376
+ processCache.isUpdating = true;
377
+ const startTime = Date.now();
378
+
379
+ try {
380
+ log.debug("Starting cache update");
381
+
382
+ // Scan for processes
383
+ const processResult = scanClaudeProcesses();
384
+ processCache.processes = processResult.processes;
385
+ processCache.detectionAvailable = processResult.detectionAvailable;
386
+ processCache.detectionError = processResult.error;
387
+
388
+ // Scan for tmux sessions
389
+ processCache.tmuxSessions = scanClaudeTmuxSessions();
390
+
391
+ processCache.lastUpdated = Date.now();
392
+
393
+ const duration = Date.now() - startTime;
394
+ log.info(
395
+ {
396
+ processCount: processCache.processes.length,
397
+ tmuxCount: processCache.tmuxSessions.length,
398
+ durationMs: duration,
399
+ },
400
+ "Process cache updated",
401
+ );
402
+ } catch (e) {
403
+ log.error({ error: e.message }, "Failed to update process cache");
404
+ processCache.detectionError = e.message;
405
+ } finally {
406
+ processCache.isUpdating = false;
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Get the current update interval based on active mode
412
+ */
413
+ function getCurrentInterval() {
414
+ return isActiveMode
415
+ ? ACTIVE_CACHE_UPDATE_INTERVAL
416
+ : IDLE_CACHE_UPDATE_INTERVAL;
417
+ }
418
+
419
+ /**
420
+ * Start the background cache update loop
421
+ */
422
+ function startCacheUpdater() {
423
+ // Perform initial update immediately
424
+ updateCache();
425
+
426
+ // Schedule periodic updates (start in idle mode)
427
+ updateInterval = setInterval(updateCache, getCurrentInterval());
428
+
429
+ log.info(
430
+ { intervalMs: getCurrentInterval(), isActiveMode },
431
+ "Process cache updater started",
432
+ );
433
+ }
434
+
435
+ /**
436
+ * Set the process cache active mode
437
+ * When active (WebSocket clients connected), updates more frequently
438
+ * @param {boolean} active - Whether the cache should be in active mode
439
+ */
440
+ function setProcessCacheActive(active) {
441
+ if (isActiveMode === active) return;
442
+
443
+ isActiveMode = active;
444
+
445
+ // Restart the interval with the new timing
446
+ if (updateInterval) {
447
+ clearInterval(updateInterval);
448
+ updateInterval = setInterval(updateCache, getCurrentInterval());
449
+
450
+ log.info(
451
+ { isActiveMode, intervalMs: getCurrentInterval() },
452
+ "Process cache interval updated",
453
+ );
454
+
455
+ // If becoming active, do an immediate update
456
+ if (active) {
457
+ updateCache();
458
+ }
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Stop the background cache update loop
464
+ */
465
+ function stopCacheUpdater() {
466
+ if (updateInterval) {
467
+ clearInterval(updateInterval);
468
+ updateInterval = null;
469
+ log.info("Process cache updater stopped");
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Get cached process data
475
+ * @returns {{ processes: Array, tmuxSessions: Array, detectionAvailable: boolean, detectionError: string | null, lastUpdated: number | null }}
476
+ */
477
+ function getCachedProcessData() {
478
+ return {
479
+ processes: processCache.processes,
480
+ tmuxSessions: processCache.tmuxSessions,
481
+ detectionAvailable: processCache.detectionAvailable,
482
+ detectionError: processCache.detectionError,
483
+ lastUpdated: processCache.lastUpdated,
484
+ };
485
+ }
486
+
487
+ /**
488
+ * Force an immediate cache update
489
+ */
490
+ async function forceUpdate() {
491
+ await updateCache();
492
+ }
493
+
494
+ /**
495
+ * Get cache age in milliseconds
496
+ * @returns {number | null}
497
+ */
498
+ function getCacheAge() {
499
+ if (!processCache.lastUpdated) return null;
500
+ return Date.now() - processCache.lastUpdated;
501
+ }
502
+
503
+ export {
504
+ startCacheUpdater,
505
+ stopCacheUpdater,
506
+ getCachedProcessData,
507
+ forceUpdate,
508
+ getCacheAge,
509
+ setProcessCacheActive,
510
+ CACHE_UPDATE_INTERVAL,
511
+ IDLE_CACHE_UPDATE_INTERVAL,
512
+ ACTIVE_CACHE_UPDATE_INTERVAL,
513
+ };
@@ -618,10 +618,11 @@ async function getSessions(projectName, limit = 5, offset = 0) {
618
618
  filesWithStats.sort((a, b) => b.mtime - a.mtime);
619
619
 
620
620
  const allSessions = new Map();
621
- const allEntries = [];
622
621
  const uuidToSessionMap = new Map();
622
+ const allFirstUserMessages = []; // Lightweight: only { uuid, sessionId }
623
623
 
624
- // Collect all sessions and entries from all files
624
+ // Collect all sessions and lightweight uuid index from all files
625
+ // Memory optimization: we no longer store full entries, only minimal metadata
625
626
  for (const { file } of filesWithStats) {
626
627
  const jsonlFile = path.join(projectDir, file);
627
628
  const result = await parseJsonlSessions(jsonlFile);
@@ -632,60 +633,52 @@ async function getSessions(projectName, limit = 5, offset = 0) {
632
633
  }
633
634
  });
634
635
 
635
- allEntries.push(...result.entries);
636
+ // Merge uuid index (lightweight - only uuid -> sessionId mappings)
637
+ result.uuidIndex.uuidToSessionId.forEach((sessionId, uuid) => {
638
+ uuidToSessionMap.set(uuid, sessionId);
639
+ });
640
+
641
+ // Collect first user messages for timeline grouping
642
+ allFirstUserMessages.push(...result.uuidIndex.firstUserMessages);
636
643
 
637
644
  // Early exit optimization for large projects
638
645
  if (
639
646
  allSessions.size >= (limit + offset) * 2 &&
640
- allEntries.length >= Math.min(3, filesWithStats.length)
647
+ allFirstUserMessages.length >= Math.min(3, filesWithStats.length)
641
648
  ) {
642
649
  break;
643
650
  }
644
651
  }
645
652
 
646
- // Build UUID-to-session mapping for timeline detection
647
- allEntries.forEach((entry) => {
648
- if (entry.uuid && entry.sessionId) {
649
- uuidToSessionMap.set(entry.uuid, entry.sessionId);
650
- }
651
- });
652
-
653
653
  // Group sessions by first user message ID
654
+ // Note: uuidToSessionMap is already built from uuidIndex during file processing above
654
655
  const sessionGroups = new Map(); // firstUserMsgId -> { latestSession, allSessions[] }
655
656
  const sessionToFirstUserMsgId = new Map(); // sessionId -> firstUserMsgId
656
657
 
657
- // Find the first user message for each session
658
- allEntries.forEach((entry) => {
659
- if (
660
- entry.sessionId &&
661
- entry.type === "user" &&
662
- entry.parentUuid === null &&
663
- entry.uuid
664
- ) {
665
- // This is a first user message in a session (parentUuid is null)
666
- const firstUserMsgId = entry.uuid;
667
-
668
- if (!sessionToFirstUserMsgId.has(entry.sessionId)) {
669
- sessionToFirstUserMsgId.set(entry.sessionId, firstUserMsgId);
670
-
671
- const session = allSessions.get(entry.sessionId);
672
- if (session) {
673
- if (!sessionGroups.has(firstUserMsgId)) {
674
- sessionGroups.set(firstUserMsgId, {
675
- latestSession: session,
676
- allSessions: [session],
677
- });
678
- } else {
679
- const group = sessionGroups.get(firstUserMsgId);
680
- group.allSessions.push(session);
658
+ // Find the first user message for each session using lightweight index
659
+ allFirstUserMessages.forEach(({ uuid, sessionId }) => {
660
+ const firstUserMsgId = uuid;
681
661
 
682
- // Update latest session if this one is more recent
683
- if (
684
- new Date(session.lastActivity) >
685
- new Date(group.latestSession.lastActivity)
686
- ) {
687
- group.latestSession = session;
688
- }
662
+ if (!sessionToFirstUserMsgId.has(sessionId)) {
663
+ sessionToFirstUserMsgId.set(sessionId, firstUserMsgId);
664
+
665
+ const session = allSessions.get(sessionId);
666
+ if (session) {
667
+ if (!sessionGroups.has(firstUserMsgId)) {
668
+ sessionGroups.set(firstUserMsgId, {
669
+ latestSession: session,
670
+ allSessions: [session],
671
+ });
672
+ } else {
673
+ const group = sessionGroups.get(firstUserMsgId);
674
+ group.allSessions.push(session);
675
+
676
+ // Update latest session if this one is more recent
677
+ if (
678
+ new Date(session.lastActivity) >
679
+ new Date(group.latestSession.lastActivity)
680
+ ) {
681
+ group.latestSession = session;
689
682
  }
690
683
  }
691
684
  }
@@ -736,7 +729,12 @@ async function getSessions(projectName, limit = 5, offset = 0) {
736
729
 
737
730
  async function parseJsonlSessions(filePath) {
738
731
  const sessions = new Map();
739
- const entries = [];
732
+ // Lightweight index for timeline detection - only stores minimal metadata, not full entries
733
+ // This reduces memory from ~2-4GB to ~50MB for large projects
734
+ const uuidIndex = {
735
+ uuidToSessionId: new Map(), // uuid -> sessionId (for timeline detection)
736
+ firstUserMessages: [], // Array of { uuid, sessionId } for entries with parentUuid === null and type === "user"
737
+ };
740
738
  const pendingSummaries = new Map(); // leafUuid -> summary for entries without sessionId
741
739
 
742
740
  try {
@@ -750,7 +748,23 @@ async function parseJsonlSessions(filePath) {
750
748
  if (line.trim()) {
751
749
  try {
752
750
  const entry = JSON.parse(line);
753
- entries.push(entry);
751
+
752
+ // Build lightweight uuid index for timeline detection (instead of storing full entry)
753
+ if (entry.uuid && entry.sessionId) {
754
+ uuidIndex.uuidToSessionId.set(entry.uuid, entry.sessionId);
755
+ }
756
+ // Track first user messages (parentUuid === null) for timeline grouping
757
+ if (
758
+ entry.type === "user" &&
759
+ entry.parentUuid === null &&
760
+ entry.uuid &&
761
+ entry.sessionId
762
+ ) {
763
+ uuidIndex.firstUserMessages.push({
764
+ uuid: entry.uuid,
765
+ sessionId: entry.sessionId,
766
+ });
767
+ }
754
768
 
755
769
  // Handle summary entries that don't have sessionId yet
756
770
  if (
@@ -881,7 +895,7 @@ async function parseJsonlSessions(filePath) {
881
895
  // After processing all entries, set final summary based on last message if no summary exists
882
896
  for (const session of sessions.values()) {
883
897
  if (session.summary === "New Session") {
884
- // Prefer last user message, fall back to last assistant message
898
+ // Use last user message, then last assistant message as fallback
885
899
  const lastMessage =
886
900
  session.lastUserMessage || session.lastAssistantMessage;
887
901
  if (lastMessage) {
@@ -908,11 +922,14 @@ async function parseJsonlSessions(filePath) {
908
922
 
909
923
  return {
910
924
  sessions: filteredSessions,
911
- entries: entries,
925
+ uuidIndex: uuidIndex,
912
926
  };
913
927
  } catch (error) {
914
928
  console.error("Error reading JSONL file:", error);
915
- return { sessions: [], entries: [] };
929
+ return {
930
+ sessions: [],
931
+ uuidIndex: { uuidToSessionId: new Map(), firstUserMessages: [] },
932
+ };
916
933
  }
917
934
  }
918
935