@agent-link/server 0.1.131 → 0.1.133

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.
@@ -38,6 +38,10 @@ export function createConnection(deps) {
38
38
  let filePreview = null;
39
39
  function setFilePreview(fp) { filePreview = fp; }
40
40
 
41
+ // Team module — set after creation to resolve circular dependency
42
+ let team = null;
43
+ function setTeam(t) { team = t; }
44
+
41
45
  let ws = null;
42
46
  let sessionKey = null;
43
47
  let reconnectAttempts = 0;
@@ -105,7 +109,7 @@ export function createConnection(deps) {
105
109
  id: ++cache.messageIdCounter, role: 'tool',
106
110
  toolId: h.toolId || '', toolName: h.toolName || 'unknown',
107
111
  toolInput: h.toolInput || '', hasResult: true,
108
- expanded: (h.toolName === 'Edit' || h.toolName === 'TodoWrite'),
112
+ expanded: (h.toolName === 'Edit' || h.toolName === 'TodoWrite' || h.toolName === 'Agent'),
109
113
  timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
110
114
  });
111
115
  }
@@ -177,7 +181,7 @@ export function createConnection(deps) {
177
181
  id: ++cache.messageIdCounter, role: 'tool',
178
182
  toolId: tool.id, toolName: tool.name || 'unknown',
179
183
  toolInput: tool.input ? JSON.stringify(tool.input, null, 2) : '',
180
- hasResult: false, expanded: (tool.name === 'Edit' || tool.name === 'TodoWrite'),
184
+ hasResult: false, expanded: (tool.name === 'Edit' || tool.name === 'TodoWrite' || tool.name === 'Agent'),
181
185
  timestamp: new Date(),
182
186
  };
183
187
  msgs.push(toolMsg);
@@ -383,7 +387,7 @@ export function createConnection(deps) {
383
387
  id: streaming.nextId(), role: 'tool',
384
388
  toolId: tool.id, toolName: tool.name || 'unknown',
385
389
  toolInput: tool.input ? JSON.stringify(tool.input, null, 2) : '',
386
- hasResult: false, expanded: (tool.name === 'Edit' || tool.name === 'TodoWrite'), timestamp: new Date(),
390
+ hasResult: false, expanded: (tool.name === 'Edit' || tool.name === 'TodoWrite' || tool.name === 'Agent'), timestamp: new Date(),
387
391
  };
388
392
  messages.value.push(toolMsg);
389
393
  if (tool.id) toolMsgMap.set(tool.id, toolMsg);
@@ -473,6 +477,16 @@ export function createConnection(deps) {
473
477
  msg = parsed;
474
478
  }
475
479
 
480
+ // ── Team messages: route before normal conversation routing ──
481
+ if (team && (msg.type?.startsWith('team_') || msg.type === 'teams_list' || (msg.type === 'claude_output' && msg.teamId))) {
482
+ if (msg.type === 'claude_output' && msg.teamId) {
483
+ team.handleTeamAgentOutput(msg);
484
+ } else {
485
+ team.handleTeamMessage(msg);
486
+ }
487
+ return;
488
+ }
489
+
476
490
  // ── Multi-session: route messages to background conversations ──
477
491
  // Messages with a conversationId that doesn't match the current foreground
478
492
  // conversation are routed to their cached background state.
@@ -510,6 +524,7 @@ export function createConnection(deps) {
510
524
  wsSend({ type: 'change_workdir', workDir: savedDir });
511
525
  }
512
526
  sidebar.requestSessionList();
527
+ if (team) team.requestTeamsList();
513
528
  startPing();
514
529
  wsSend({ type: 'query_active_conversations' });
515
530
  } else {
@@ -553,6 +568,7 @@ export function createConnection(deps) {
553
568
  sidebar.addToWorkdirHistory(msg.agent.workDir);
554
569
  }
555
570
  sidebar.requestSessionList();
571
+ if (team) team.requestTeamsList();
556
572
  startPing();
557
573
  wsSend({ type: 'query_active_conversations' });
558
574
  } else if (msg.type === 'active_conversations') {
@@ -607,6 +623,14 @@ export function createConnection(deps) {
607
623
  processingConversations.value[convId] = true;
608
624
  }
609
625
  }
626
+
627
+ // Restore active team state on reconnect
628
+ if (team && msg.activeTeam) {
629
+ team.handleActiveTeamRestore(msg.activeTeam);
630
+ } else if (team && !msg.activeTeam && msg.lastCompletedTeamId) {
631
+ // Team completed before page refresh — auto-load as historical view
632
+ team.viewHistoricalTeam(msg.lastCompletedTeamId);
633
+ }
610
634
  } else if (msg.type === 'error') {
611
635
  streaming.flushReveal();
612
636
  finalizeStreamingMsg(scheduleHighlight);
@@ -755,7 +779,7 @@ export function createConnection(deps) {
755
779
  id: streaming.nextId(), role: 'tool',
756
780
  toolId: h.toolId || '', toolName: h.toolName || 'unknown',
757
781
  toolInput: h.toolInput || '', hasResult: true,
758
- expanded: (h.toolName === 'Edit' || h.toolName === 'TodoWrite'), timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
782
+ expanded: (h.toolName === 'Edit' || h.toolName === 'TodoWrite' || h.toolName === 'Agent'), timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
759
783
  });
760
784
  }
761
785
  }
@@ -880,5 +904,5 @@ export function createConnection(deps) {
880
904
  ws.send(JSON.stringify({ type: 'authenticate', password: pwd }));
881
905
  }
882
906
 
883
- return { connect, wsSend, closeWs, submitPassword, setDequeueNext, setFileBrowser, setFilePreview, getToolMsgMap, restoreToolMsgMap, clearToolMsgMap };
907
+ return { connect, wsSend, closeWs, submitPassword, setDequeueNext, setFileBrowser, setFilePreview, setTeam, getToolMsgMap, restoreToolMsgMap, clearToolMsgMap };
884
908
  }
@@ -73,6 +73,7 @@ const TOOL_SVG = {
73
73
  Glob: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 1 1-1.06 1.06l-3.04-3.04zM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7z"/></svg>',
74
74
  Grep: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 1 1-1.06 1.06l-3.04-3.04zM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7z"/></svg>',
75
75
  Task: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25V2.75C0 1.784.784 1 1.75 1zm0 1.5a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25H1.75zM3.5 5h9v1.5h-9V5zm0 3h9v1.5h-9V8zm0 3h5v1.5h-5V11z"/></svg>',
76
+ Agent: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M10.5 5a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0zm.061 3.073a4 4 0 1 0-5.123 0 6.004 6.004 0 0 0-3.431 5.142.75.75 0 0 0 1.498.07 4.5 4.5 0 0 1 8.99 0 .75.75 0 1 0 1.498-.07 6.004 6.004 0 0 0-3.432-5.142z"/></svg>',
76
77
  WebFetch: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zm3.7 5.3a.75.75 0 0 0-1.06-1.06l-5.5 5.5a.75.75 0 1 0 1.06 1.06l5.5-5.5zM8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13z"/></svg>',
77
78
  WebSearch: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zm3.7 5.3a.75.75 0 0 0-1.06-1.06l-5.5 5.5a.75.75 0 1 0 1.06 1.06l5.5-5.5zM8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13z"/></svg>',
78
79
  TodoWrite: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 1.042-1.08L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0z"/></svg>',
@@ -69,6 +69,8 @@ export function getToolSummary(msg) {
69
69
  return `${done}/${obj.todos.length} done`;
70
70
  }
71
71
  if (name === 'Task' && obj.description) return obj.description;
72
+ if (name === 'Agent' && obj.description) return obj.description;
73
+ if (name === 'Agent' && obj.prompt) return obj.prompt.length > 80 ? obj.prompt.slice(0, 80) + '...' : obj.prompt;
72
74
  if (name === 'WebSearch' && obj.query) return obj.query;
73
75
  if (name === 'WebFetch' && obj.url) return obj.url.length > 60 ? obj.url.slice(0, 60) + '...' : obj.url;
74
76
  } catch {}
@@ -135,13 +137,12 @@ export function getFormattedToolInput(msg) {
135
137
  return html;
136
138
  }
137
139
 
138
- if (name === 'Task') {
140
+ if (name === 'Task' || name === 'Agent') {
139
141
  let html = '';
140
142
  if (obj.description) html += '<div class="task-field"><span class="tool-input-meta">Description</span> ' + esc(obj.description) + '</div>';
141
143
  if (obj.subagent_type) html += '<div class="task-field"><span class="tool-input-meta">Agent</span> <code class="tool-input-cmd">' + esc(obj.subagent_type) + '</code></div>';
142
144
  if (obj.prompt) {
143
- const short = obj.prompt.length > 200 ? obj.prompt.slice(0, 200) + '...' : obj.prompt;
144
- html += '<div class="task-field"><span class="tool-input-meta">Prompt</span></div><div class="task-prompt">' + esc(short) + '</div>';
145
+ html += '<div class="task-field"><span class="tool-input-meta">Prompt</span></div><div class="task-prompt">' + esc(obj.prompt) + '</div>';
145
146
  }
146
147
  if (html) return html;
147
148
  }
@@ -0,0 +1,342 @@
1
+ // ── Team mode: state management and message routing ───────────────────────
2
+ const { ref, computed } = Vue;
3
+
4
+ const MAX_FEED_ENTRIES = 200;
5
+
6
+ // Color palette (matches agent/src/team.ts AGENT_COLORS)
7
+ const AGENT_COLORS = [
8
+ '#EF4444', '#EAB308', '#3B82F6', '#10B981', '#8B5CF6',
9
+ '#F97316', '#EC4899', '#06B6D4', '#84CC16', '#6366F1',
10
+ ];
11
+
12
+ /**
13
+ * Creates the team mode controller.
14
+ * @param {object} deps
15
+ * @param {Function} deps.wsSend
16
+ * @param {Function} deps.scrollToBottom
17
+ */
18
+ export function createTeam(deps) {
19
+ const { wsSend, scrollToBottom } = deps;
20
+
21
+ // ── Reactive state ──
22
+
23
+ /** @type {import('vue').Ref<object|null>} Current team state (TeamStateSerialized or null) */
24
+ const teamState = ref(null);
25
+
26
+ /** @type {import('vue').Ref<string>} 'chat' | 'team' — current input mode */
27
+ const teamMode = ref('chat');
28
+
29
+ /** @type {import('vue').Ref<string|null>} Currently viewed agent ID, null = dashboard */
30
+ const activeAgentView = ref(null);
31
+
32
+ /** @type {import('vue').Ref<object|null>} Historical team loaded for read-only viewing */
33
+ const historicalTeam = ref(null);
34
+
35
+ /** @type {import('vue').Ref<Array>} Teams list from server */
36
+ const teamsList = ref([]);
37
+
38
+ /** Per-agent message accumulator: agentId → message[] */
39
+ const agentMessages = ref({});
40
+
41
+ /** Per-agent message ID counter */
42
+ let agentMsgIdCounter = 0;
43
+
44
+ // ── Computed ──
45
+
46
+ const isTeamActive = computed(() => teamState.value !== null && teamState.value.status !== 'completed' && teamState.value.status !== 'failed');
47
+ const isTeamRunning = computed(() => teamState.value !== null && (teamState.value.status === 'running' || teamState.value.status === 'planning' || teamState.value.status === 'summarizing'));
48
+
49
+ /** The team being displayed: active or historical */
50
+ const displayTeam = computed(() => historicalTeam.value || teamState.value);
51
+
52
+ const pendingTasks = computed(() => {
53
+ const t = displayTeam.value;
54
+ if (!t) return [];
55
+ return t.tasks.filter(task => task.status === 'pending');
56
+ });
57
+ const activeTasks = computed(() => {
58
+ const t = displayTeam.value;
59
+ if (!t) return [];
60
+ return t.tasks.filter(task => task.status === 'active');
61
+ });
62
+ const doneTasks = computed(() => {
63
+ const t = displayTeam.value;
64
+ if (!t) return [];
65
+ return t.tasks.filter(task => task.status === 'done');
66
+ });
67
+ const failedTasks = computed(() => {
68
+ const t = displayTeam.value;
69
+ if (!t) return [];
70
+ return t.tasks.filter(task => task.status === 'failed');
71
+ });
72
+
73
+ // ── Methods ──
74
+
75
+ function launchTeam(instruction, template) {
76
+ wsSend({ type: 'create_team', instruction, template: template || 'custom' });
77
+ }
78
+
79
+ function dissolveTeam() {
80
+ wsSend({ type: 'dissolve_team' });
81
+ }
82
+
83
+ function viewAgent(agentId) {
84
+ activeAgentView.value = agentId;
85
+ }
86
+
87
+ function viewDashboard() {
88
+ activeAgentView.value = null;
89
+ }
90
+
91
+ function viewHistoricalTeam(teamId) {
92
+ wsSend({ type: 'get_team', teamId });
93
+ }
94
+
95
+ function requestTeamsList() {
96
+ wsSend({ type: 'list_teams' });
97
+ }
98
+
99
+ function requestAgentHistory(teamId, agentId) {
100
+ wsSend({ type: 'get_team_agent_history', teamId, agentId });
101
+ }
102
+
103
+ function getAgentColor(agentId) {
104
+ if (agentId === 'lead') return '#A78BFA'; // purple for lead
105
+ const t = displayTeam.value;
106
+ if (!t || !t.agents) return AGENT_COLORS[0];
107
+ const idx = t.agents.findIndex(a => a.id === agentId);
108
+ return idx >= 0 ? AGENT_COLORS[idx % AGENT_COLORS.length] : AGENT_COLORS[0];
109
+ }
110
+
111
+ function findAgent(agentId) {
112
+ if (agentId === 'lead') return { id: 'lead', name: 'Lead', color: '#A78BFA', status: 'working' };
113
+ const t = displayTeam.value;
114
+ if (!t || !t.agents) return null;
115
+ return t.agents.find(a => a.id === agentId) || null;
116
+ }
117
+
118
+ function getAgentMessages(agentId) {
119
+ return agentMessages.value[agentId] || [];
120
+ }
121
+
122
+ function backToChat() {
123
+ teamMode.value = 'chat';
124
+ historicalTeam.value = null;
125
+ activeAgentView.value = null;
126
+ }
127
+
128
+ function newTeam() {
129
+ historicalTeam.value = null;
130
+ activeAgentView.value = null;
131
+ // If completed team is still in teamState, clear it so create panel shows
132
+ if (teamState.value && (teamState.value.status === 'completed' || teamState.value.status === 'failed')) {
133
+ teamState.value = null;
134
+ }
135
+ requestTeamsList();
136
+ }
137
+
138
+ // ── Message routing ──
139
+
140
+ /**
141
+ * Handle incoming team-related messages from the WebSocket.
142
+ * Returns true if the message was consumed (should not be processed further).
143
+ */
144
+ function handleTeamMessage(msg) {
145
+ switch (msg.type) {
146
+ case 'team_created':
147
+ teamState.value = msg.team;
148
+ teamMode.value = 'team';
149
+ historicalTeam.value = null;
150
+ activeAgentView.value = null;
151
+ agentMessages.value = {};
152
+ agentMsgIdCounter = 0;
153
+ // Initialize lead message list
154
+ agentMessages.value['lead'] = [];
155
+ // Initialize agent message lists
156
+ if (msg.team.agents) {
157
+ for (const agent of msg.team.agents) {
158
+ agentMessages.value[agent.id] = [];
159
+ }
160
+ }
161
+ return true;
162
+
163
+ case 'team_agent_status': {
164
+ if (!teamState.value || teamState.value.teamId !== msg.teamId) return false;
165
+ const agent = msg.agent;
166
+ const existing = teamState.value.agents.find(a => a.id === agent.id);
167
+ if (existing) {
168
+ existing.status = agent.status;
169
+ existing.taskId = agent.taskId;
170
+ } else {
171
+ // New agent joined
172
+ teamState.value.agents.push(agent);
173
+ if (!agentMessages.value[agent.id]) {
174
+ agentMessages.value[agent.id] = [];
175
+ }
176
+ }
177
+ // Update team status to running when first subagent appears
178
+ if (teamState.value.status === 'planning') {
179
+ teamState.value.status = 'running';
180
+ }
181
+ return true;
182
+ }
183
+
184
+ case 'team_task_update': {
185
+ if (!teamState.value || teamState.value.teamId !== msg.teamId) return false;
186
+ const task = msg.task;
187
+ const idx = teamState.value.tasks.findIndex(t => t.id === task.id);
188
+ if (idx >= 0) {
189
+ teamState.value.tasks[idx] = task;
190
+ } else {
191
+ teamState.value.tasks.push(task);
192
+ }
193
+ return true;
194
+ }
195
+
196
+ case 'team_feed': {
197
+ if (!teamState.value || teamState.value.teamId !== msg.teamId) return false;
198
+ teamState.value.feed.push(msg.entry);
199
+ // Cap feed entries
200
+ if (teamState.value.feed.length > MAX_FEED_ENTRIES) {
201
+ teamState.value.feed = teamState.value.feed.slice(-MAX_FEED_ENTRIES);
202
+ }
203
+ return true;
204
+ }
205
+
206
+ case 'team_completed': {
207
+ if (!teamState.value || teamState.value.teamId !== msg.teamId) return false;
208
+ // Update with final state from server
209
+ teamState.value = msg.team;
210
+ return true;
211
+ }
212
+
213
+ case 'team_lead_status': {
214
+ if (!teamState.value || teamState.value.teamId !== msg.teamId) return false;
215
+ teamState.value.leadStatus = msg.leadStatus;
216
+ return true;
217
+ }
218
+
219
+ case 'teams_list':
220
+ teamsList.value = msg.teams || [];
221
+ return true;
222
+
223
+ case 'team_detail':
224
+ historicalTeam.value = msg.team;
225
+ teamMode.value = 'team';
226
+ activeAgentView.value = null;
227
+ return true;
228
+
229
+ case 'team_agent_history': {
230
+ if (msg.agentId) {
231
+ if (msg.messages && msg.messages.length > 0) {
232
+ // Default expand tool messages in history view
233
+ for (const m of msg.messages) {
234
+ if (m.role === 'tool' && m.expanded === undefined) m.expanded = true;
235
+ }
236
+ agentMessages.value[msg.agentId] = msg.messages;
237
+ } else {
238
+ agentMessages.value[msg.agentId] = [];
239
+ }
240
+ }
241
+ return true;
242
+ }
243
+
244
+ default:
245
+ return false;
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Handle claude_output messages tagged with teamId + agentRole.
251
+ * Accumulates per-agent messages for agent detail view.
252
+ * Returns true if consumed.
253
+ */
254
+ function handleTeamAgentOutput(msg) {
255
+ if (!msg.teamId || !msg.agentRole) return false;
256
+ if (!teamState.value || teamState.value.teamId !== msg.teamId) return false;
257
+
258
+ const agentId = msg.agentRole;
259
+ if (!agentMessages.value[agentId]) {
260
+ agentMessages.value[agentId] = [];
261
+ }
262
+ const msgs = agentMessages.value[agentId];
263
+ const data = msg.data;
264
+ if (!data) return true;
265
+
266
+ if (data.type === 'content_block_delta' && data.delta) {
267
+ // Append text to last assistant message (or create new one)
268
+ const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
269
+ if (last && last.role === 'assistant' && last.isStreaming) {
270
+ last.content += data.delta;
271
+ } else {
272
+ msgs.push({
273
+ id: ++agentMsgIdCounter, role: 'assistant',
274
+ content: data.delta, isStreaming: true, timestamp: Date.now(),
275
+ });
276
+ }
277
+ } else if (data.type === 'tool_use' && data.tools) {
278
+ // Finalize streaming message
279
+ const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
280
+ if (last && last.role === 'assistant' && last.isStreaming) {
281
+ last.isStreaming = false;
282
+ }
283
+ for (const tool of data.tools) {
284
+ msgs.push({
285
+ id: ++agentMsgIdCounter, role: 'tool',
286
+ toolId: tool.id, toolName: tool.name || 'unknown',
287
+ toolInput: tool.input ? JSON.stringify(tool.input, null, 2) : '',
288
+ hasResult: false, expanded: true, timestamp: Date.now(),
289
+ });
290
+ }
291
+ } else if (data.type === 'user' && data.tool_use_result) {
292
+ const result = data.tool_use_result;
293
+ const results = Array.isArray(result) ? result : [result];
294
+ for (const r of results) {
295
+ const toolMsg = msgs.find(m => m.role === 'tool' && m.toolId === r.tool_use_id);
296
+ if (toolMsg) {
297
+ toolMsg.toolOutput = typeof r.content === 'string'
298
+ ? r.content : JSON.stringify(r.content, null, 2);
299
+ toolMsg.hasResult = true;
300
+ }
301
+ }
302
+ }
303
+
304
+ return true;
305
+ }
306
+
307
+ /**
308
+ * Handle active_conversations response that includes activeTeam.
309
+ * Called on initial connect + reconnect to restore team state.
310
+ */
311
+ function handleActiveTeamRestore(activeTeam) {
312
+ if (!activeTeam) return;
313
+ teamState.value = activeTeam;
314
+ teamMode.value = 'team';
315
+ // Re-initialize agent message lists (messages lost on reconnect)
316
+ if (!agentMessages.value['lead']) {
317
+ agentMessages.value['lead'] = [];
318
+ }
319
+ if (activeTeam.agents) {
320
+ for (const agent of activeTeam.agents) {
321
+ if (!agentMessages.value[agent.id]) {
322
+ agentMessages.value[agent.id] = [];
323
+ }
324
+ }
325
+ }
326
+ }
327
+
328
+ return {
329
+ // State
330
+ teamState, teamMode, activeAgentView, historicalTeam, teamsList,
331
+ agentMessages,
332
+ // Computed
333
+ isTeamActive, isTeamRunning, displayTeam,
334
+ pendingTasks, activeTasks, doneTasks, failedTasks,
335
+ // Methods
336
+ launchTeam, dissolveTeam, viewAgent, viewDashboard,
337
+ viewHistoricalTeam, requestTeamsList, requestAgentHistory,
338
+ getAgentColor, findAgent, getAgentMessages, backToChat, newTeam,
339
+ // Message handling
340
+ handleTeamMessage, handleTeamAgentOutput, handleActiveTeamRestore,
341
+ };
342
+ }