ai-agent-session-center 1.0.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.
- package/README.md +618 -0
- package/bin/cli.js +20 -0
- package/hooks/dashboard-hook-codex.sh +67 -0
- package/hooks/dashboard-hook-gemini.sh +102 -0
- package/hooks/dashboard-hook.ps1 +147 -0
- package/hooks/dashboard-hook.sh +142 -0
- package/hooks/dashboard-hooks-backup.json +103 -0
- package/hooks/install-hooks.js +543 -0
- package/hooks/reset.js +357 -0
- package/hooks/setup-wizard.js +156 -0
- package/package.json +52 -0
- package/public/css/dashboard.css +10200 -0
- package/public/index.html +915 -0
- package/public/js/analyticsPanel.js +467 -0
- package/public/js/app.js +1148 -0
- package/public/js/browserDb.js +806 -0
- package/public/js/chartUtils.js +383 -0
- package/public/js/historyPanel.js +298 -0
- package/public/js/movementManager.js +155 -0
- package/public/js/navController.js +32 -0
- package/public/js/robotManager.js +526 -0
- package/public/js/sceneManager.js +7 -0
- package/public/js/sessionPanel.js +2477 -0
- package/public/js/settingsManager.js +924 -0
- package/public/js/soundManager.js +249 -0
- package/public/js/statsPanel.js +118 -0
- package/public/js/terminalManager.js +391 -0
- package/public/js/timelinePanel.js +278 -0
- package/public/js/wsClient.js +88 -0
- package/server/apiRouter.js +321 -0
- package/server/config.js +120 -0
- package/server/hookProcessor.js +55 -0
- package/server/hookRouter.js +18 -0
- package/server/hookStats.js +107 -0
- package/server/index.js +314 -0
- package/server/logger.js +67 -0
- package/server/mqReader.js +218 -0
- package/server/serverConfig.js +27 -0
- package/server/sessionStore.js +1049 -0
- package/server/sshManager.js +339 -0
- package/server/wsManager.js +83 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// sshManager.js — PTY-based terminal multiplexer using node-pty
|
|
2
|
+
// Manages terminal lifecycle for local and remote (via native ssh) sessions.
|
|
3
|
+
// Terminal I/O is relayed through WebSocket to xterm.js in the browser.
|
|
4
|
+
|
|
5
|
+
import pty from 'node-pty';
|
|
6
|
+
import { execFile, execSync } from 'child_process';
|
|
7
|
+
import { readdirSync } from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import { homedir } from 'os';
|
|
10
|
+
import log from './logger.js';
|
|
11
|
+
|
|
12
|
+
// List available SSH keys from ~/.ssh/
|
|
13
|
+
export function listSshKeys() {
|
|
14
|
+
const sshDir = join(homedir(), '.ssh');
|
|
15
|
+
try {
|
|
16
|
+
return readdirSync(sshDir)
|
|
17
|
+
.filter(f => !f.endsWith('.pub') && !f.startsWith('known_hosts') && !f.startsWith('config') && !f.startsWith('authorized_keys') && !f.startsWith('.'))
|
|
18
|
+
.map(f => ({ name: f, path: join('~', '.ssh', f) }));
|
|
19
|
+
} catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Active terminals: terminalId -> { pty, sessionId, config, wsClient, createdAt }
|
|
25
|
+
const terminals = new Map();
|
|
26
|
+
|
|
27
|
+
// Pending links: workingDir -> { terminalId, host, createdAt }
|
|
28
|
+
// Used to match incoming SessionStart hooks to the terminal that launched Claude
|
|
29
|
+
const pendingLinks = new Map();
|
|
30
|
+
|
|
31
|
+
// Clean up stale pending links every 30s
|
|
32
|
+
setInterval(() => {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
for (const [key, link] of pendingLinks) {
|
|
35
|
+
if (now - link.createdAt > 60000) {
|
|
36
|
+
log.debug('pty', `Expired pending link for ${key}`);
|
|
37
|
+
pendingLinks.delete(key);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}, 30000);
|
|
41
|
+
|
|
42
|
+
function resolveWorkDir(dir) {
|
|
43
|
+
if (!dir || dir === '~') return homedir();
|
|
44
|
+
return dir.replace(/^~/, homedir());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isLocal(host) {
|
|
48
|
+
return !host || host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getDefaultShell() {
|
|
52
|
+
return process.env.SHELL || '/bin/bash';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Build SSH command args for remote connections (without -t for non-interactive)
|
|
56
|
+
function buildSshArgs(config, { allocatePty = false } = {}) {
|
|
57
|
+
const args = [];
|
|
58
|
+
if (allocatePty) args.push('-t');
|
|
59
|
+
if (config.port && config.port !== 22) {
|
|
60
|
+
args.push('-p', String(config.port));
|
|
61
|
+
}
|
|
62
|
+
if (config.privateKeyPath) {
|
|
63
|
+
const keyPath = config.privateKeyPath.replace(/^~/, homedir());
|
|
64
|
+
args.push('-i', keyPath);
|
|
65
|
+
}
|
|
66
|
+
args.push('-o', 'StrictHostKeyChecking=accept-new');
|
|
67
|
+
args.push(`${config.username}@${config.host}`);
|
|
68
|
+
return args;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// List tmux sessions on local or remote host
|
|
72
|
+
export function listTmuxSessions(config) {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const tmuxFmt = 'tmux list-sessions -F "#{session_name}||#{session_attached}||#{session_created}||#{session_windows}" 2>/dev/null || echo "__no_tmux__"';
|
|
75
|
+
|
|
76
|
+
let cmd, args;
|
|
77
|
+
if (isLocal(config.host)) {
|
|
78
|
+
cmd = 'bash';
|
|
79
|
+
args = ['-c', tmuxFmt];
|
|
80
|
+
} else {
|
|
81
|
+
cmd = 'ssh';
|
|
82
|
+
args = [...buildSshArgs(config), tmuxFmt];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
execFile(cmd, args, { timeout: 10000 }, (err, stdout) => {
|
|
86
|
+
if (err) {
|
|
87
|
+
if (err.killed) {
|
|
88
|
+
reject(new Error('Connection timed out'));
|
|
89
|
+
} else {
|
|
90
|
+
// tmux not installed or no sessions — not an error
|
|
91
|
+
resolve([]);
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const output = stdout.toString();
|
|
96
|
+
if (output.includes('__no_tmux__') || !output.trim()) {
|
|
97
|
+
resolve([]);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const sessions = output.trim().split('\n').map(line => {
|
|
101
|
+
const [name, attached, created, windows] = line.split('||');
|
|
102
|
+
return {
|
|
103
|
+
name,
|
|
104
|
+
attached: attached === '1',
|
|
105
|
+
created: parseInt(created) * 1000,
|
|
106
|
+
windows: parseInt(windows) || 1,
|
|
107
|
+
};
|
|
108
|
+
}).filter(s => s.name);
|
|
109
|
+
resolve(sessions);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function createTerminal(config, wsClient) {
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
const terminalId = `term-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
117
|
+
const workDir = resolveWorkDir(config.workingDir);
|
|
118
|
+
const command = config.command || 'claude';
|
|
119
|
+
const local = isLocal(config.host);
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
let shell, args, cwd;
|
|
123
|
+
|
|
124
|
+
if (local) {
|
|
125
|
+
shell = getDefaultShell();
|
|
126
|
+
args = [];
|
|
127
|
+
cwd = workDir;
|
|
128
|
+
} else {
|
|
129
|
+
// Spawn native ssh — uses system SSH config, agent, keys automatically
|
|
130
|
+
shell = 'ssh';
|
|
131
|
+
args = buildSshArgs(config, { allocatePty: true });
|
|
132
|
+
cwd = homedir();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const ptyProcess = pty.spawn(shell, args, {
|
|
136
|
+
name: 'xterm-256color',
|
|
137
|
+
cols: 120,
|
|
138
|
+
rows: 40,
|
|
139
|
+
cwd,
|
|
140
|
+
env: { ...process.env, AGENT_MANAGER_TERMINAL_ID: terminalId },
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
log.info('pty', `Spawned ${local ? 'local' : `remote (${config.host})`} terminal ${terminalId} (pid: ${ptyProcess.pid})`);
|
|
144
|
+
|
|
145
|
+
terminals.set(terminalId, {
|
|
146
|
+
pty: ptyProcess,
|
|
147
|
+
sessionId: null,
|
|
148
|
+
config: { ...config, workingDir: workDir },
|
|
149
|
+
wsClient,
|
|
150
|
+
createdAt: Date.now(),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Register pending link for session matching
|
|
154
|
+
pendingLinks.set(workDir, { terminalId, host: config.host || 'localhost', createdAt: Date.now() });
|
|
155
|
+
|
|
156
|
+
// Stream output to WebSocket client
|
|
157
|
+
ptyProcess.onData((data) => {
|
|
158
|
+
const term = terminals.get(terminalId);
|
|
159
|
+
if (term && term.wsClient && term.wsClient.readyState === 1) {
|
|
160
|
+
term.wsClient.send(JSON.stringify({
|
|
161
|
+
type: 'terminal_output',
|
|
162
|
+
terminalId,
|
|
163
|
+
data: Buffer.from(data).toString('base64'),
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
ptyProcess.onExit(({ exitCode, signal }) => {
|
|
169
|
+
log.info('pty', `Terminal ${terminalId} exited (code: ${exitCode}, signal: ${signal})`);
|
|
170
|
+
broadcastToClient(terminalId, {
|
|
171
|
+
type: 'terminal_closed',
|
|
172
|
+
terminalId,
|
|
173
|
+
reason: signal ? `signal ${signal}` : 'exited',
|
|
174
|
+
});
|
|
175
|
+
cleanup(terminalId);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Send the launch command after shell/SSH init
|
|
179
|
+
setTimeout(() => {
|
|
180
|
+
let launchCmd;
|
|
181
|
+
|
|
182
|
+
if (config.tmuxSession) {
|
|
183
|
+
// Attach to existing tmux session
|
|
184
|
+
launchCmd = `tmux attach -t "${config.tmuxSession}"`;
|
|
185
|
+
} else if (config.useTmux) {
|
|
186
|
+
// Wrap command in a new tmux session
|
|
187
|
+
const tmuxName = `claude-${Date.now().toString(36)}`;
|
|
188
|
+
let innerCmd = local ? '' : `cd "${workDir}" && `;
|
|
189
|
+
if (config.apiKey) {
|
|
190
|
+
const envVar = command.startsWith('codex') ? 'OPENAI_API_KEY'
|
|
191
|
+
: command.startsWith('gemini') ? 'GEMINI_API_KEY'
|
|
192
|
+
: 'ANTHROPIC_API_KEY';
|
|
193
|
+
innerCmd += `export ${envVar}='${config.apiKey.replace(/'/g, "'\\''")}' && `;
|
|
194
|
+
}
|
|
195
|
+
innerCmd += command;
|
|
196
|
+
launchCmd = `tmux new-session -s "${tmuxName}" '${innerCmd.replace(/'/g, "'\\''")}'`;
|
|
197
|
+
} else {
|
|
198
|
+
// Direct launch
|
|
199
|
+
launchCmd = local ? '' : `cd "${workDir}"`;
|
|
200
|
+
if (config.apiKey) {
|
|
201
|
+
if (launchCmd) launchCmd += ' && ';
|
|
202
|
+
const envVar = command.startsWith('codex') ? 'OPENAI_API_KEY'
|
|
203
|
+
: command.startsWith('gemini') ? 'GEMINI_API_KEY'
|
|
204
|
+
: 'ANTHROPIC_API_KEY';
|
|
205
|
+
launchCmd += `export ${envVar}="${config.apiKey}"`;
|
|
206
|
+
}
|
|
207
|
+
if (launchCmd) launchCmd += ' && ';
|
|
208
|
+
launchCmd += command;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
ptyProcess.write(launchCmd + '\r');
|
|
212
|
+
}, local ? 100 : 500);
|
|
213
|
+
|
|
214
|
+
// Notify client terminal is ready
|
|
215
|
+
if (wsClient && wsClient.readyState === 1) {
|
|
216
|
+
wsClient.send(JSON.stringify({ type: 'terminal_ready', terminalId }));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
resolve(terminalId);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
log.error('pty', `Failed to create terminal: ${err.message}`);
|
|
222
|
+
reject(err);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function writeToTerminal(terminalId, data) {
|
|
228
|
+
const term = terminals.get(terminalId);
|
|
229
|
+
if (term && term.pty) {
|
|
230
|
+
term.pty.write(data);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function resizeTerminal(terminalId, cols, rows) {
|
|
235
|
+
const term = terminals.get(terminalId);
|
|
236
|
+
if (term && term.pty) {
|
|
237
|
+
try {
|
|
238
|
+
term.pty.resize(cols, rows);
|
|
239
|
+
} catch {
|
|
240
|
+
// Process may already be dead
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function closeTerminal(terminalId) {
|
|
246
|
+
const term = terminals.get(terminalId);
|
|
247
|
+
if (term) {
|
|
248
|
+
if (term.pty) {
|
|
249
|
+
try { term.pty.kill(); } catch {}
|
|
250
|
+
}
|
|
251
|
+
cleanup(terminalId);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function linkSession(terminalId, sessionId) {
|
|
256
|
+
const term = terminals.get(terminalId);
|
|
257
|
+
if (term) {
|
|
258
|
+
term.sessionId = sessionId;
|
|
259
|
+
log.info('pty', `Linked terminal ${terminalId} to session ${sessionId}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function tryLinkByWorkDir(workDir, sessionId) {
|
|
264
|
+
const link = pendingLinks.get(workDir);
|
|
265
|
+
if (link) {
|
|
266
|
+
linkSession(link.terminalId, sessionId);
|
|
267
|
+
pendingLinks.delete(workDir);
|
|
268
|
+
return link.terminalId;
|
|
269
|
+
}
|
|
270
|
+
// Also try matching with trailing slash variants
|
|
271
|
+
const normalized = workDir.replace(/\/$/, '');
|
|
272
|
+
for (const [dir, link] of pendingLinks) {
|
|
273
|
+
if (dir.replace(/\/$/, '') === normalized) {
|
|
274
|
+
linkSession(link.terminalId, sessionId);
|
|
275
|
+
pendingLinks.delete(dir);
|
|
276
|
+
return link.terminalId;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function getTerminalForSession(sessionId) {
|
|
283
|
+
for (const [terminalId, term] of terminals) {
|
|
284
|
+
if (term.sessionId === sessionId) return terminalId;
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Find terminal whose pty is the parent of the given child PID
|
|
290
|
+
export function getTerminalByPtyChild(childPid) {
|
|
291
|
+
if (!childPid || childPid <= 0) return null;
|
|
292
|
+
try {
|
|
293
|
+
const ppid = parseInt(execSync(`ps -o ppid= -p ${childPid} 2>/dev/null`, { encoding: 'utf-8' }).trim(), 10);
|
|
294
|
+
if (!ppid || ppid <= 0) return null;
|
|
295
|
+
for (const [terminalId, term] of terminals) {
|
|
296
|
+
if (term.pty && term.pty.pid === ppid) return terminalId;
|
|
297
|
+
}
|
|
298
|
+
} catch {}
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function setWsClient(terminalId, wsClient) {
|
|
303
|
+
const term = terminals.get(terminalId);
|
|
304
|
+
if (term) {
|
|
305
|
+
term.wsClient = wsClient;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function getTerminals() {
|
|
310
|
+
const result = [];
|
|
311
|
+
for (const [terminalId, term] of terminals) {
|
|
312
|
+
result.push({
|
|
313
|
+
terminalId,
|
|
314
|
+
sessionId: term.sessionId,
|
|
315
|
+
host: term.config.host,
|
|
316
|
+
workingDir: term.config.workingDir,
|
|
317
|
+
command: term.config.command,
|
|
318
|
+
createdAt: term.createdAt,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function broadcastToClient(terminalId, message) {
|
|
325
|
+
const term = terminals.get(terminalId);
|
|
326
|
+
if (term && term.wsClient && term.wsClient.readyState === 1) {
|
|
327
|
+
term.wsClient.send(JSON.stringify(message));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function cleanup(terminalId) {
|
|
332
|
+
const term = terminals.get(terminalId);
|
|
333
|
+
if (term) {
|
|
334
|
+
for (const [key, link] of pendingLinks) {
|
|
335
|
+
if (link.terminalId === terminalId) pendingLinks.delete(key);
|
|
336
|
+
}
|
|
337
|
+
terminals.delete(terminalId);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// wsManager.js — WebSocket broadcast manager with bidirectional terminal support
|
|
2
|
+
import { getAllSessions, getAllTeams, getEventSeq, getEventsSince } from './sessionStore.js';
|
|
3
|
+
import { writeToTerminal, resizeTerminal, closeTerminal, setWsClient } from './sshManager.js';
|
|
4
|
+
import log from './logger.js';
|
|
5
|
+
|
|
6
|
+
const clients = new Set();
|
|
7
|
+
|
|
8
|
+
export function handleConnection(ws) {
|
|
9
|
+
clients.add(ws);
|
|
10
|
+
ws._terminalIds = new Set(); // Track subscribed terminals
|
|
11
|
+
log.info('ws', `Client connected (total: ${clients.size})`);
|
|
12
|
+
|
|
13
|
+
// Send full snapshot on connect (includes teams + event sequence for replay)
|
|
14
|
+
const sessions = getAllSessions();
|
|
15
|
+
const teams = getAllTeams();
|
|
16
|
+
const seq = getEventSeq();
|
|
17
|
+
log.debug('ws', `Sending snapshot: ${Object.keys(sessions).length} sessions, ${Object.keys(teams).length} teams, seq=${seq}`);
|
|
18
|
+
ws.send(JSON.stringify({ type: 'snapshot', sessions, teams, seq }));
|
|
19
|
+
|
|
20
|
+
// Handle incoming messages (terminal input, resize, etc.)
|
|
21
|
+
ws.on('message', (raw) => {
|
|
22
|
+
try {
|
|
23
|
+
const msg = JSON.parse(raw.toString());
|
|
24
|
+
switch (msg.type) {
|
|
25
|
+
case 'terminal_input':
|
|
26
|
+
if (msg.terminalId && msg.data) {
|
|
27
|
+
writeToTerminal(msg.terminalId, msg.data);
|
|
28
|
+
}
|
|
29
|
+
break;
|
|
30
|
+
case 'terminal_resize':
|
|
31
|
+
if (msg.terminalId && msg.cols && msg.rows) {
|
|
32
|
+
resizeTerminal(msg.terminalId, msg.cols, msg.rows);
|
|
33
|
+
}
|
|
34
|
+
break;
|
|
35
|
+
case 'terminal_disconnect':
|
|
36
|
+
if (msg.terminalId) {
|
|
37
|
+
closeTerminal(msg.terminalId);
|
|
38
|
+
ws._terminalIds.delete(msg.terminalId);
|
|
39
|
+
}
|
|
40
|
+
break;
|
|
41
|
+
case 'terminal_subscribe':
|
|
42
|
+
if (msg.terminalId) {
|
|
43
|
+
ws._terminalIds.add(msg.terminalId);
|
|
44
|
+
setWsClient(msg.terminalId, ws);
|
|
45
|
+
}
|
|
46
|
+
break;
|
|
47
|
+
case 'replay':
|
|
48
|
+
// Client reconnected and wants events since a certain sequence number
|
|
49
|
+
if (typeof msg.sinceSeq === 'number') {
|
|
50
|
+
const missed = getEventsSince(msg.sinceSeq);
|
|
51
|
+
log.debug('ws', `Replaying ${missed.length} events since seq=${msg.sinceSeq}`);
|
|
52
|
+
for (const evt of missed) {
|
|
53
|
+
ws.send(JSON.stringify(evt.data));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
break;
|
|
57
|
+
default:
|
|
58
|
+
log.debug('ws', `Unknown message type: ${msg.type}`);
|
|
59
|
+
}
|
|
60
|
+
} catch (e) {
|
|
61
|
+
log.debug('ws', `Invalid WS message: ${e.message}`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
ws.on('close', () => {
|
|
66
|
+
clients.delete(ws);
|
|
67
|
+
log.info('ws', `Client disconnected (total: ${clients.size})`);
|
|
68
|
+
});
|
|
69
|
+
ws.on('error', (err) => {
|
|
70
|
+
clients.delete(ws);
|
|
71
|
+
log.error('ws', 'Client error:', err.message);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function broadcast(data) {
|
|
76
|
+
const msg = JSON.stringify(data);
|
|
77
|
+
log.debug('ws', `Broadcasting ${data.type} to ${clients.size} clients`);
|
|
78
|
+
for (const client of clients) {
|
|
79
|
+
if (client.readyState === 1) {
|
|
80
|
+
client.send(msg);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|