@orgloop/agentctl 1.1.0 → 1.2.1

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,682 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import { watch } from "node:fs";
4
+ import * as fs from "node:fs/promises";
5
+ import * as os from "node:os";
6
+ import * as path from "node:path";
7
+ import { promisify } from "node:util";
8
+ import { buildSpawnEnv } from "../utils/daemon-env.js";
9
+ import { resolveBinaryPath } from "../utils/resolve-binary.js";
10
+ const execFileAsync = promisify(execFile);
11
+ const DEFAULT_STORAGE_DIR = path.join(os.homedir(), ".local", "share", "opencode", "storage");
12
+ // Default: only show stopped sessions from the last 7 days
13
+ const STOPPED_SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
14
+ /**
15
+ * Compute the project hash matching OpenCode's approach: SHA1 of the directory path.
16
+ */
17
+ export function computeProjectHash(directory) {
18
+ return crypto.createHash("sha1").update(directory).digest("hex");
19
+ }
20
+ /**
21
+ * OpenCode adapter — reads session data from ~/.local/share/opencode/storage/
22
+ * and cross-references with running opencode processes.
23
+ */
24
+ export class OpenCodeAdapter {
25
+ id = "opencode";
26
+ storageDir;
27
+ sessionDir;
28
+ messageDir;
29
+ sessionsMetaDir;
30
+ getPids;
31
+ isProcessAlive;
32
+ constructor(opts) {
33
+ this.storageDir = opts?.storageDir || DEFAULT_STORAGE_DIR;
34
+ this.sessionDir = path.join(this.storageDir, "session");
35
+ this.messageDir = path.join(this.storageDir, "message");
36
+ this.sessionsMetaDir =
37
+ opts?.sessionsMetaDir ||
38
+ path.join(os.homedir(), ".agentctl", "opencode-sessions");
39
+ this.getPids = opts?.getPids || getOpenCodePids;
40
+ this.isProcessAlive = opts?.isProcessAlive || defaultIsProcessAlive;
41
+ }
42
+ async list(opts) {
43
+ const runningPids = await this.getPids();
44
+ const sessions = [];
45
+ let projectDirs;
46
+ try {
47
+ projectDirs = await fs.readdir(this.sessionDir);
48
+ }
49
+ catch {
50
+ return [];
51
+ }
52
+ for (const projHash of projectDirs) {
53
+ const projPath = path.join(this.sessionDir, projHash);
54
+ const stat = await fs.stat(projPath).catch(() => null);
55
+ if (!stat?.isDirectory())
56
+ continue;
57
+ const sessionFiles = await this.getSessionFilesForProject(projPath);
58
+ for (const sessionData of sessionFiles) {
59
+ const session = await this.buildSession(sessionData, runningPids);
60
+ // Filter by status
61
+ if (opts?.status && session.status !== opts.status)
62
+ continue;
63
+ // If not --all, skip old stopped sessions
64
+ if (!opts?.all && session.status === "stopped") {
65
+ const age = Date.now() - session.startedAt.getTime();
66
+ if (age > STOPPED_SESSION_MAX_AGE_MS)
67
+ continue;
68
+ }
69
+ // Default: only show running sessions unless --all
70
+ if (!opts?.all &&
71
+ !opts?.status &&
72
+ session.status !== "running" &&
73
+ session.status !== "idle") {
74
+ continue;
75
+ }
76
+ sessions.push(session);
77
+ }
78
+ }
79
+ // Sort: running first, then by most recent
80
+ sessions.sort((a, b) => {
81
+ if (a.status === "running" && b.status !== "running")
82
+ return -1;
83
+ if (b.status === "running" && a.status !== "running")
84
+ return 1;
85
+ return b.startedAt.getTime() - a.startedAt.getTime();
86
+ });
87
+ return sessions;
88
+ }
89
+ async peek(sessionId, opts) {
90
+ const lines = opts?.lines ?? 20;
91
+ const resolved = await this.resolveSessionId(sessionId);
92
+ if (!resolved)
93
+ throw new Error(`Session not found: ${sessionId}`);
94
+ const messages = await this.readMessages(resolved.id);
95
+ const assistantMessages = [];
96
+ for (const msg of messages) {
97
+ if (msg.role === "assistant") {
98
+ // Read message content parts
99
+ const text = await this.readMessageParts(msg.id);
100
+ if (text) {
101
+ assistantMessages.push(text);
102
+ }
103
+ else if (msg.error?.data?.message) {
104
+ assistantMessages.push(`[error] ${msg.error.data.message}`);
105
+ }
106
+ }
107
+ }
108
+ // Take last N messages
109
+ const recent = assistantMessages.slice(-lines);
110
+ return recent.join("\n---\n");
111
+ }
112
+ async status(sessionId) {
113
+ const runningPids = await this.getPids();
114
+ const resolved = await this.resolveSessionId(sessionId);
115
+ if (!resolved)
116
+ throw new Error(`Session not found: ${sessionId}`);
117
+ return this.buildSession(resolved, runningPids);
118
+ }
119
+ async launch(opts) {
120
+ const args = ["run", opts.prompt];
121
+ const env = buildSpawnEnv(undefined, opts.env);
122
+ const cwd = opts.cwd || process.cwd();
123
+ await fs.mkdir(this.sessionsMetaDir, { recursive: true });
124
+ const opencodePath = await resolveBinaryPath("opencode");
125
+ const child = spawn(opencodePath, args, {
126
+ cwd,
127
+ env,
128
+ stdio: ["ignore", "pipe", "pipe"],
129
+ detached: true,
130
+ });
131
+ child.on("error", (err) => {
132
+ console.error(`[opencode] spawn error: ${err.message}`);
133
+ });
134
+ child.unref();
135
+ const pid = child.pid;
136
+ const now = new Date();
137
+ // Generate a pending session ID — will be resolved when OpenCode creates the session file
138
+ const sessionId = pid ? `pending-${pid}` : crypto.randomUUID();
139
+ // Persist session metadata so status checks work after wrapper exits
140
+ if (pid) {
141
+ await this.writeSessionMeta({
142
+ sessionId,
143
+ pid,
144
+ wrapperPid: process.pid,
145
+ cwd,
146
+ model: opts.model,
147
+ prompt: opts.prompt.slice(0, 200),
148
+ launchedAt: now.toISOString(),
149
+ });
150
+ }
151
+ const session = {
152
+ id: sessionId,
153
+ adapter: this.id,
154
+ status: "running",
155
+ startedAt: now,
156
+ cwd,
157
+ model: opts.model,
158
+ prompt: opts.prompt.slice(0, 200),
159
+ pid,
160
+ meta: {
161
+ adapterOpts: opts.adapterOpts,
162
+ spec: opts.spec,
163
+ },
164
+ };
165
+ return session;
166
+ }
167
+ async stop(sessionId, opts) {
168
+ const pid = await this.findPidForSession(sessionId);
169
+ if (!pid)
170
+ throw new Error(`No running process for session: ${sessionId}`);
171
+ if (opts?.force) {
172
+ process.kill(pid, "SIGINT");
173
+ await sleep(5000);
174
+ try {
175
+ process.kill(pid, "SIGKILL");
176
+ }
177
+ catch {
178
+ // Already dead — good
179
+ }
180
+ }
181
+ else {
182
+ process.kill(pid, "SIGTERM");
183
+ }
184
+ }
185
+ async resume(sessionId, message) {
186
+ // OpenCode doesn't have a native resume command — launch a new session
187
+ // with the same working directory
188
+ const resolved = await this.resolveSessionId(sessionId);
189
+ if (!resolved)
190
+ throw new Error(`Session not found for resume: ${sessionId}`);
191
+ const cwd = resolved.directory || process.cwd();
192
+ const opencodePath = await resolveBinaryPath("opencode");
193
+ const child = spawn(opencodePath, ["run", message], {
194
+ cwd,
195
+ stdio: ["ignore", "pipe", "pipe"],
196
+ detached: true,
197
+ });
198
+ child.on("error", (err) => {
199
+ console.error(`[opencode] resume spawn error: ${err.message}`);
200
+ });
201
+ child.unref();
202
+ }
203
+ async *events() {
204
+ let knownSessions = new Map();
205
+ const initial = await this.list({ all: true });
206
+ for (const s of initial) {
207
+ knownSessions.set(s.id, s);
208
+ }
209
+ // Poll + fs.watch hybrid
210
+ let watcher;
211
+ try {
212
+ watcher = watch(this.sessionDir, { recursive: true });
213
+ }
214
+ catch {
215
+ // Directory may not exist
216
+ }
217
+ try {
218
+ while (true) {
219
+ await sleep(5000);
220
+ const current = await this.list({ all: true });
221
+ const currentMap = new Map(current.map((s) => [s.id, s]));
222
+ for (const [id, session] of currentMap) {
223
+ const prev = knownSessions.get(id);
224
+ if (!prev) {
225
+ yield {
226
+ type: "session.started",
227
+ adapter: this.id,
228
+ sessionId: id,
229
+ session,
230
+ timestamp: new Date(),
231
+ };
232
+ }
233
+ else if (prev.status === "running" &&
234
+ session.status === "stopped") {
235
+ yield {
236
+ type: "session.stopped",
237
+ adapter: this.id,
238
+ sessionId: id,
239
+ session,
240
+ timestamp: new Date(),
241
+ };
242
+ }
243
+ else if (prev.status === "running" && session.status === "idle") {
244
+ yield {
245
+ type: "session.idle",
246
+ adapter: this.id,
247
+ sessionId: id,
248
+ session,
249
+ timestamp: new Date(),
250
+ };
251
+ }
252
+ }
253
+ knownSessions = currentMap;
254
+ }
255
+ }
256
+ finally {
257
+ watcher?.close();
258
+ }
259
+ }
260
+ // --- Private helpers ---
261
+ /**
262
+ * Read all session JSON files for a project directory.
263
+ */
264
+ async getSessionFilesForProject(projPath) {
265
+ const results = [];
266
+ let files;
267
+ try {
268
+ files = await fs.readdir(projPath);
269
+ }
270
+ catch {
271
+ return [];
272
+ }
273
+ for (const file of files) {
274
+ if (!file.endsWith(".json"))
275
+ continue;
276
+ const fullPath = path.join(projPath, file);
277
+ try {
278
+ const content = await fs.readFile(fullPath, "utf-8");
279
+ const sessionData = JSON.parse(content);
280
+ if (sessionData.id) {
281
+ results.push(sessionData);
282
+ }
283
+ }
284
+ catch {
285
+ // Skip malformed files
286
+ }
287
+ }
288
+ return results;
289
+ }
290
+ /**
291
+ * Build an AgentSession from an OpenCode session file.
292
+ */
293
+ async buildSession(sessionData, runningPids) {
294
+ const isRunning = await this.isSessionRunning(sessionData, runningPids);
295
+ // Read messages for token/model/cost info
296
+ const { model, tokens, cost } = await this.aggregateMessageStats(sessionData.id);
297
+ const createdAt = sessionData.time?.created
298
+ ? new Date(sessionData.time.created)
299
+ : new Date();
300
+ const updatedAt = sessionData.time?.updated
301
+ ? new Date(sessionData.time.updated)
302
+ : undefined;
303
+ return {
304
+ id: sessionData.id,
305
+ adapter: this.id,
306
+ status: isRunning ? "running" : "stopped",
307
+ startedAt: createdAt,
308
+ stoppedAt: isRunning ? undefined : updatedAt,
309
+ cwd: sessionData.directory,
310
+ model,
311
+ prompt: sessionData.title?.slice(0, 200),
312
+ tokens,
313
+ cost,
314
+ pid: isRunning
315
+ ? await this.findMatchingPid(sessionData, runningPids)
316
+ : undefined,
317
+ meta: {
318
+ projectID: sessionData.projectID,
319
+ slug: sessionData.slug,
320
+ summary: sessionData.summary,
321
+ version: sessionData.version,
322
+ },
323
+ };
324
+ }
325
+ /**
326
+ * Check whether a session is currently running by cross-referencing PIDs.
327
+ */
328
+ async isSessionRunning(sessionData, runningPids) {
329
+ const directory = sessionData.directory;
330
+ if (!directory)
331
+ return false;
332
+ const sessionCreated = sessionData.time?.created
333
+ ? new Date(sessionData.time.created).getTime()
334
+ : 0;
335
+ // 1. Check running PIDs discovered via `ps aux`
336
+ for (const [, info] of runningPids) {
337
+ if (info.cwd === directory) {
338
+ if (this.processStartedAfterSession(info, sessionCreated))
339
+ return true;
340
+ }
341
+ }
342
+ // 2. Check persisted session metadata (for detached processes)
343
+ const meta = await this.readSessionMeta(sessionData.id);
344
+ if (meta?.pid) {
345
+ if (this.isProcessAlive(meta.pid)) {
346
+ // Cross-check: if this PID appears in runningPids with a DIFFERENT
347
+ // start time than what we recorded, the PID was recycled.
348
+ const pidInfo = runningPids.get(meta.pid);
349
+ if (pidInfo?.startTime && meta.startTime) {
350
+ const currentStartMs = new Date(pidInfo.startTime).getTime();
351
+ const recordedStartMs = new Date(meta.startTime).getTime();
352
+ if (!Number.isNaN(currentStartMs) &&
353
+ !Number.isNaN(recordedStartMs) &&
354
+ Math.abs(currentStartMs - recordedStartMs) > 5000) {
355
+ await this.deleteSessionMeta(sessionData.id);
356
+ return false;
357
+ }
358
+ }
359
+ // Verify stored start time is consistent with launch time
360
+ if (meta.startTime) {
361
+ const metaStartMs = new Date(meta.startTime).getTime();
362
+ const sessionMs = new Date(meta.launchedAt).getTime();
363
+ if (!Number.isNaN(metaStartMs) && metaStartMs >= sessionMs - 5000) {
364
+ return true;
365
+ }
366
+ await this.deleteSessionMeta(sessionData.id);
367
+ return false;
368
+ }
369
+ return true;
370
+ }
371
+ await this.deleteSessionMeta(sessionData.id);
372
+ }
373
+ // 3. Fallback: check if session was updated very recently (last 60s)
374
+ if (sessionData.time?.updated) {
375
+ const updatedMs = new Date(sessionData.time.updated).getTime();
376
+ const age = Date.now() - updatedMs;
377
+ if (age < 60_000) {
378
+ for (const [, info] of runningPids) {
379
+ if (info.cwd === directory &&
380
+ this.processStartedAfterSession(info, sessionCreated)) {
381
+ return true;
382
+ }
383
+ }
384
+ }
385
+ }
386
+ return false;
387
+ }
388
+ /**
389
+ * Check whether a process plausibly belongs to a session by verifying
390
+ * the process started at or after the session's creation time.
391
+ */
392
+ processStartedAfterSession(info, sessionCreatedMs) {
393
+ if (!info.startTime)
394
+ return false;
395
+ const processStartMs = new Date(info.startTime).getTime();
396
+ if (Number.isNaN(processStartMs))
397
+ return false;
398
+ return processStartMs >= sessionCreatedMs - 5000;
399
+ }
400
+ async findMatchingPid(sessionData, runningPids) {
401
+ const directory = sessionData.directory;
402
+ const sessionCreated = sessionData.time?.created
403
+ ? new Date(sessionData.time.created).getTime()
404
+ : 0;
405
+ for (const [pid, info] of runningPids) {
406
+ if (info.cwd === directory) {
407
+ if (this.processStartedAfterSession(info, sessionCreated))
408
+ return pid;
409
+ }
410
+ }
411
+ const meta = await this.readSessionMeta(sessionData.id);
412
+ if (meta?.pid && this.isProcessAlive(meta.pid)) {
413
+ return meta.pid;
414
+ }
415
+ return undefined;
416
+ }
417
+ /**
418
+ * Read all messages for a session and aggregate stats.
419
+ */
420
+ async aggregateMessageStats(sessionId) {
421
+ const messages = await this.readMessages(sessionId);
422
+ let model;
423
+ let totalIn = 0;
424
+ let totalOut = 0;
425
+ let totalCost = 0;
426
+ for (const msg of messages) {
427
+ if (msg.role === "assistant") {
428
+ if (msg.modelID)
429
+ model = msg.modelID;
430
+ else if (msg.model?.modelID)
431
+ model = msg.model.modelID;
432
+ if (msg.tokens) {
433
+ totalIn += msg.tokens.input || 0;
434
+ totalOut += msg.tokens.output || 0;
435
+ }
436
+ if (msg.cost)
437
+ totalCost += msg.cost;
438
+ }
439
+ }
440
+ return {
441
+ model,
442
+ tokens: totalIn || totalOut ? { in: totalIn, out: totalOut } : undefined,
443
+ cost: totalCost > 0 ? totalCost : undefined,
444
+ };
445
+ }
446
+ /**
447
+ * Read all message files for a session, sorted by time.
448
+ */
449
+ async readMessages(sessionId) {
450
+ const msgDir = path.join(this.messageDir, sessionId);
451
+ const messages = [];
452
+ let files;
453
+ try {
454
+ files = await fs.readdir(msgDir);
455
+ }
456
+ catch {
457
+ return [];
458
+ }
459
+ for (const file of files) {
460
+ if (!file.endsWith(".json"))
461
+ continue;
462
+ try {
463
+ const content = await fs.readFile(path.join(msgDir, file), "utf-8");
464
+ const msg = JSON.parse(content);
465
+ if (msg.id)
466
+ messages.push(msg);
467
+ }
468
+ catch {
469
+ // Skip malformed files
470
+ }
471
+ }
472
+ // Sort by creation time
473
+ messages.sort((a, b) => {
474
+ const aTime = a.time?.created ? new Date(a.time.created).getTime() : 0;
475
+ const bTime = b.time?.created ? new Date(b.time.created).getTime() : 0;
476
+ return aTime - bTime;
477
+ });
478
+ return messages;
479
+ }
480
+ /**
481
+ * Read message content parts from storage/part/<messageId>/
482
+ */
483
+ async readMessageParts(messageId) {
484
+ const partDir = path.join(this.storageDir, "part", messageId);
485
+ const parts = [];
486
+ let files;
487
+ try {
488
+ files = await fs.readdir(partDir);
489
+ }
490
+ catch {
491
+ return "";
492
+ }
493
+ // Sort part files to maintain order
494
+ files.sort();
495
+ for (const file of files) {
496
+ try {
497
+ const content = await fs.readFile(path.join(partDir, file), "utf-8");
498
+ try {
499
+ const parsed = JSON.parse(content);
500
+ if (typeof parsed.text === "string") {
501
+ parts.push(parsed.text);
502
+ }
503
+ else if (typeof parsed.content === "string") {
504
+ parts.push(parsed.content);
505
+ }
506
+ else if (typeof parsed === "string") {
507
+ parts.push(parsed);
508
+ }
509
+ }
510
+ catch {
511
+ // Not JSON — treat as raw text
512
+ if (content.trim())
513
+ parts.push(content.trim());
514
+ }
515
+ }
516
+ catch {
517
+ // Skip unreadable files
518
+ }
519
+ }
520
+ return parts.join("\n");
521
+ }
522
+ /**
523
+ * Resolve a session ID (supports prefix matching).
524
+ */
525
+ async resolveSessionId(sessionId) {
526
+ let projectDirs;
527
+ try {
528
+ projectDirs = await fs.readdir(this.sessionDir);
529
+ }
530
+ catch {
531
+ return null;
532
+ }
533
+ for (const projHash of projectDirs) {
534
+ const projPath = path.join(this.sessionDir, projHash);
535
+ const stat = await fs.stat(projPath).catch(() => null);
536
+ if (!stat?.isDirectory())
537
+ continue;
538
+ const sessionFiles = await this.getSessionFilesForProject(projPath);
539
+ const match = sessionFiles.find((s) => s.id === sessionId || s.id.startsWith(sessionId));
540
+ if (match)
541
+ return match;
542
+ }
543
+ return null;
544
+ }
545
+ async findPidForSession(sessionId) {
546
+ const session = await this.status(sessionId);
547
+ return session.pid ?? null;
548
+ }
549
+ // --- Session metadata persistence ---
550
+ async writeSessionMeta(meta) {
551
+ await fs.mkdir(this.sessionsMetaDir, { recursive: true });
552
+ let startTime;
553
+ try {
554
+ const { stdout } = await execFileAsync("ps", [
555
+ "-p",
556
+ meta.pid.toString(),
557
+ "-o",
558
+ "lstart=",
559
+ ]);
560
+ startTime = stdout.trim() || undefined;
561
+ }
562
+ catch {
563
+ // Process may have already exited or ps failed
564
+ }
565
+ const fullMeta = { ...meta, startTime };
566
+ const metaPath = path.join(this.sessionsMetaDir, `${meta.sessionId}.json`);
567
+ await fs.writeFile(metaPath, JSON.stringify(fullMeta, null, 2));
568
+ }
569
+ async readSessionMeta(sessionId) {
570
+ const metaPath = path.join(this.sessionsMetaDir, `${sessionId}.json`);
571
+ try {
572
+ const raw = await fs.readFile(metaPath, "utf-8");
573
+ return JSON.parse(raw);
574
+ }
575
+ catch {
576
+ // File doesn't exist or is unreadable
577
+ }
578
+ // Scan all metadata files for one whose sessionId matches
579
+ try {
580
+ const files = await fs.readdir(this.sessionsMetaDir);
581
+ for (const file of files) {
582
+ if (!file.endsWith(".json"))
583
+ continue;
584
+ try {
585
+ const raw = await fs.readFile(path.join(this.sessionsMetaDir, file), "utf-8");
586
+ const meta = JSON.parse(raw);
587
+ if (meta.sessionId === sessionId)
588
+ return meta;
589
+ }
590
+ catch {
591
+ // skip
592
+ }
593
+ }
594
+ }
595
+ catch {
596
+ // Dir doesn't exist
597
+ }
598
+ return null;
599
+ }
600
+ async deleteSessionMeta(sessionId) {
601
+ for (const id of [sessionId, `pending-${sessionId}`]) {
602
+ const metaPath = path.join(this.sessionsMetaDir, `${id}.json`);
603
+ try {
604
+ await fs.unlink(metaPath);
605
+ }
606
+ catch {
607
+ // File doesn't exist
608
+ }
609
+ }
610
+ }
611
+ }
612
+ // --- Utility functions ---
613
+ function defaultIsProcessAlive(pid) {
614
+ try {
615
+ process.kill(pid, 0);
616
+ return true;
617
+ }
618
+ catch {
619
+ return false;
620
+ }
621
+ }
622
+ async function getOpenCodePids() {
623
+ const pids = new Map();
624
+ try {
625
+ const { stdout } = await execFileAsync("ps", ["aux"]);
626
+ for (const line of stdout.split("\n")) {
627
+ if (!line.includes("opencode") || line.includes("grep"))
628
+ continue;
629
+ const fields = line.trim().split(/\s+/);
630
+ if (fields.length < 11)
631
+ continue;
632
+ const pid = parseInt(fields[1], 10);
633
+ const command = fields.slice(10).join(" ");
634
+ // Match opencode processes (run, serve, etc.)
635
+ if (!command.includes("opencode"))
636
+ continue;
637
+ if (pid === process.pid)
638
+ continue;
639
+ // Try to extract working directory from lsof
640
+ let cwd = "";
641
+ try {
642
+ const { stdout: lsofOut } = await execFileAsync("/usr/sbin/lsof", [
643
+ "-p",
644
+ pid.toString(),
645
+ "-Fn",
646
+ ]);
647
+ const lsofLines = lsofOut.split("\n");
648
+ for (let i = 0; i < lsofLines.length; i++) {
649
+ if (lsofLines[i] === "fcwd" && lsofLines[i + 1]?.startsWith("n")) {
650
+ cwd = lsofLines[i + 1].slice(1);
651
+ break;
652
+ }
653
+ }
654
+ }
655
+ catch {
656
+ // lsof might fail
657
+ }
658
+ // Get process start time for PID recycling detection
659
+ let startTime;
660
+ try {
661
+ const { stdout: lstart } = await execFileAsync("ps", [
662
+ "-p",
663
+ pid.toString(),
664
+ "-o",
665
+ "lstart=",
666
+ ]);
667
+ startTime = lstart.trim() || undefined;
668
+ }
669
+ catch {
670
+ // ps might fail
671
+ }
672
+ pids.set(pid, { pid, cwd, args: command, startTime });
673
+ }
674
+ }
675
+ catch {
676
+ // ps failed — return empty
677
+ }
678
+ return pids;
679
+ }
680
+ function sleep(ms) {
681
+ return new Promise((resolve) => setTimeout(resolve, ms));
682
+ }