@link-assistant/hive-mind 1.74.0 → 1.74.2

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.
@@ -25,6 +25,16 @@ const VALID_ISOLATION_BACKENDS = ['screen', 'tmux', 'docker'];
25
25
  const RUNNING_SESSION_STATUSES = new Set(['executing', 'running']);
26
26
  const TERMINAL_SESSION_STATUSES = new Set(['executed', 'completed', 'failed', 'cancelled', 'canceled', 'error']);
27
27
 
28
+ function normalizeProcessIds(value) {
29
+ if (!value || typeof value !== 'object') return {};
30
+ const out = {};
31
+ for (const [key, raw] of Object.entries(value)) {
32
+ const number = Number(raw);
33
+ if (Number.isInteger(number) && number > 0) out[key] = number;
34
+ }
35
+ return out;
36
+ }
37
+
28
38
  /**
29
39
  * Generate a UUID v4 for unique session identification
30
40
  * @returns {string} UUID v4 string
@@ -41,12 +51,12 @@ export function generateSessionId() {
41
51
  * Keep the parser tolerant so completion monitoring survives either format.
42
52
  *
43
53
  * @param {string} output - Raw stdout from `$ --status`
44
- * @returns {{exists: boolean, uuid: string|null, status: string|null, exitCode: number|null, startTime: string|null, endTime: string|null, currentTime: string|null, logPath: string|null, command: string|null, isolation: string|null, workingDirectory: string|null, raw: string}}
54
+ * @returns {{exists: boolean, uuid: string|null, status: string|null, exitCode: number|null, startTime: string|null, endTime: string|null, currentTime: string|null, logPath: string|null, command: string|null, isolation: string|null, workingDirectory: string|null, sessionName: string|null, processIds: Object, raw: string}}
45
55
  */
46
56
  export function parseSessionStatusOutput(output) {
47
57
  const raw = (output || '').trim();
48
58
  if (!raw) {
49
- return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, raw: '' };
59
+ return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, sessionName: null, processIds: {}, raw: '' };
50
60
  }
51
61
 
52
62
  try {
@@ -58,6 +68,9 @@ export function parseSessionStatusOutput(output) {
58
68
  // field — keep accepting all three so we are tolerant of future renames.
59
69
  // See https://github.com/link-assistant/hive-mind/issues/1700.
60
70
  const isolationCandidate = (typeof data?.isolation === 'string' && data.isolation) || (typeof data?.options?.isolated === 'string' && data.options.isolated) || (typeof data?.options?.isolation === 'string' && data.options.isolation) || null;
71
+ const topPid = Number(data?.pid);
72
+ const processIds = normalizeProcessIds(data?.processIds);
73
+ if (Number.isInteger(topPid) && topPid > 0 && processIds.pid == null) processIds.pid = topPid;
61
74
  return {
62
75
  exists: true,
63
76
  uuid: data?.uuid || null,
@@ -70,6 +83,8 @@ export function parseSessionStatusOutput(output) {
70
83
  command: data?.command || null,
71
84
  isolation: isolationCandidate ? isolationCandidate.toLowerCase() : null,
72
85
  workingDirectory: data?.workingDirectory || null,
86
+ sessionName: data?.sessionName || data?.options?.sessionName || null,
87
+ processIds,
73
88
  raw,
74
89
  };
75
90
  } catch {
@@ -95,6 +110,12 @@ export function parseSessionStatusOutput(output) {
95
110
  // returned null for every real session and made /log + /terminal_watch
96
111
  // reject screen/tmux/docker sessions. See issue #1700.
97
112
  const isolationText = readField('isolated') || readField('isolation');
113
+ const processIds = {};
114
+ for (const name of ['pid', 'wrapperPid', 'childPid', 'processPid', 'commandPid']) {
115
+ const value = readField(name);
116
+ const number = Number(value);
117
+ if (Number.isInteger(number) && number > 0) processIds[name] = number;
118
+ }
98
119
 
99
120
  return {
100
121
  exists: Boolean(status || firstLine),
@@ -108,6 +129,8 @@ export function parseSessionStatusOutput(output) {
108
129
  command: readField('command'),
109
130
  isolation: isolationText?.toLowerCase() || null,
110
131
  workingDirectory: readField('workingDirectory'),
132
+ sessionName: readField('sessionName'),
133
+ processIds,
111
134
  raw,
112
135
  };
113
136
  }
@@ -234,7 +257,7 @@ export async function querySessionStatus(sessionId, verbose = false) {
234
257
  if (verbose) {
235
258
  console.log('[VERBOSE] isolation-runner: Cannot query status - $ binary not found');
236
259
  }
237
- return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, raw: '' };
260
+ return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, sessionName: null, processIds: {}, raw: '' };
238
261
  }
239
262
 
240
263
  try {
@@ -251,7 +274,7 @@ export async function querySessionStatus(sessionId, verbose = false) {
251
274
  if (verbose) {
252
275
  console.log(`[VERBOSE] isolation-runner: Status query error: ${error.message}`);
253
276
  }
254
- return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, raw: '' };
277
+ return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, sessionName: null, processIds: {}, raw: '' };
255
278
  }
256
279
  }
257
280
 
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Pure process/session correlation helpers for cleanup process diagnostics
3
+ * (issue #1851).
4
+ *
5
+ * This module intentionally avoids /proc, screen, filesystem, and network
6
+ * access. The OS layer supplies process records and start-command session
7
+ * metadata; this file only parses, matches, redacts, and formats.
8
+ */
9
+
10
+ import path from 'node:path';
11
+
12
+ import { extractTaskRefsFromCommand } from './cleanup.lib.mjs';
13
+
14
+ const AGENT_KINDS = ['claude', 'codex', 'gemini', 'qwen', 'opencode'];
15
+ const RUNNING_STATUSES = new Set(['executing', 'running']);
16
+ const TERMINAL_STATUSES = new Set(['executed', 'completed', 'failed', 'cancelled', 'canceled', 'error']);
17
+
18
+ function taskUrlFromRef(ref) {
19
+ if (!ref) return null;
20
+ const kind = ref.type === 'pull' ? 'pull' : 'issues';
21
+ return `https://github.com/${ref.owner}/${ref.repo}/${kind}/${ref.number}`;
22
+ }
23
+
24
+ function firstTaskUrl(text) {
25
+ const refs = extractTaskRefsFromCommand(text || '');
26
+ return taskUrlFromRef(refs[0]);
27
+ }
28
+
29
+ function normalizeWhitespace(value) {
30
+ return String(value || '')
31
+ .replace(/\s+/g, ' ')
32
+ .trim();
33
+ }
34
+
35
+ function toPositiveInteger(value) {
36
+ const number = Number(value);
37
+ return Number.isInteger(number) && number > 0 ? number : null;
38
+ }
39
+
40
+ function normalizeProcessIds(value) {
41
+ const out = {};
42
+ if (!value || typeof value !== 'object') return out;
43
+ for (const [key, raw] of Object.entries(value)) {
44
+ const number = toPositiveInteger(raw);
45
+ if (number) out[key] = number;
46
+ }
47
+ return out;
48
+ }
49
+
50
+ function normalizePath(value) {
51
+ if (!value || typeof value !== 'string') return null;
52
+ return value.trim().replace(/\/+$/, '') || null;
53
+ }
54
+
55
+ function isPathInside(candidate, parent) {
56
+ const child = normalizePath(candidate);
57
+ const root = normalizePath(parent);
58
+ if (!child || !root) return false;
59
+ return child === root || child.startsWith(root + path.sep);
60
+ }
61
+
62
+ function containsToken(text, token) {
63
+ return Boolean(text && token && String(text).includes(String(token)));
64
+ }
65
+
66
+ function readFlagValue(command, flag) {
67
+ if (!command) return null;
68
+ const re = new RegExp(`(?:^|\\s)${flag}(?:=|\\s+)("([^"]+)"|'([^']+)'|\\S+)`, 'i');
69
+ const match = command.match(re);
70
+ return match ? (match[2] || match[3] || match[1] || '').replace(/^["']|["']$/g, '') : null;
71
+ }
72
+
73
+ function extractWorkspace(text) {
74
+ if (!text) return null;
75
+ const clean = value => {
76
+ const raw = String(value || '')
77
+ .trim()
78
+ .replace(/^["']|["']$/g, '');
79
+ const tmpMatch = raw.match(/\/tmp\/gh-issue-solver-[A-Za-z0-9._-]+/);
80
+ if (tmpMatch) return normalizePath(tmpMatch[0]);
81
+ return normalizePath(raw.split(/\\n|\s/)[0]);
82
+ };
83
+ const patterns = [/Your prepared working directory:\s*([^\r\n]+)/i, /Creating temporary directory:\s*([^\r\n]+)/i, /Cloning into ['"]([^'"]+)['"]/i, /\bworking directory:\s*([^\r\n]+)/i, /\bworkspace(?: directory)?:\s*([^\r\n]+)/i, /\((?:cd|pushd)\s+["']?([^"')\s]+)["']?\s+&&/i, /(\/tmp\/gh-issue-solver-[A-Za-z0-9._-]+)/];
84
+
85
+ for (const pattern of patterns) {
86
+ const match = text.match(pattern);
87
+ const value = match?.[1] ? clean(match[1]) : null;
88
+ if (value) return normalizePath(value);
89
+ }
90
+ return null;
91
+ }
92
+
93
+ function detectTool(command, text) {
94
+ const fromFlag = readFlagValue(command, '--tool');
95
+ if (fromFlag) return String(fromFlag).toLowerCase();
96
+ const haystack = `${command || ''}\n${text || ''}`.toLowerCase();
97
+ for (const kind of AGENT_KINDS) {
98
+ if (new RegExp(`\\b${kind}\\b`, 'i').test(haystack)) return kind;
99
+ }
100
+ return null;
101
+ }
102
+
103
+ export function detectAgentKind(processRecord) {
104
+ const commandName = String(processRecord?.commandName || '').toLowerCase();
105
+ const exeBase = path.basename(String(processRecord?.exe || '')).toLowerCase();
106
+ const cmdline = String(processRecord?.cmdline || '').toLowerCase();
107
+ const combined = `${commandName} ${exeBase} ${cmdline}`;
108
+
109
+ for (const kind of AGENT_KINDS) {
110
+ if (commandName === kind || exeBase === kind) return kind;
111
+ if (new RegExp(`(?:^|[\\s/.-])${kind}(?:$|[\\s/.-])`).test(combined)) return kind;
112
+ }
113
+ return null;
114
+ }
115
+
116
+ /**
117
+ * Synchronous redaction for process command lines and log snippets. Process
118
+ * debugging runs inside cleanup, so it cannot rely on async full-log
119
+ * sanitizers. Keep this conservative and token-shape based.
120
+ *
121
+ * @param {string} text
122
+ * @returns {string}
123
+ */
124
+ export function redactProcessText(text) {
125
+ let out = String(text ?? '');
126
+ out = out.replace(/\b(\d{6,12}):([A-Za-z0-9_-]{20,})\b/g, '$1:[REDACTED]');
127
+ out = out.replace(/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, 'github_pat_[REDACTED]');
128
+ out = out.replace(/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, match => `${match.slice(0, 4)}[REDACTED]`);
129
+ out = out.replace(/\bsk-ant-[A-Za-z0-9_-]{20,}\b/g, 'sk-ant-[REDACTED]');
130
+ out = out.replace(/\bsk-[A-Za-z0-9_-]{20,}\b/g, 'sk-[REDACTED]');
131
+ out = out.replace(/\bxox[baprs]-[A-Za-z0-9-]{20,}\b/g, match => `${match.slice(0, 5)}[REDACTED]`);
132
+ out = out.replace(/\bnpm_[A-Za-z0-9]{20,}\b/g, 'npm_[REDACTED]');
133
+ out = out.replace(/\bhf_[A-Za-z0-9]{20,}\b/g, 'hf_[REDACTED]');
134
+ out = out.replace(/(Authorization:\s*Bearer\s+)([A-Za-z0-9._~+/=-]{10,})/gi, '$1[REDACTED]');
135
+ out = out.replace(/((?:api[_-]?key|token|secret|password|bot[_-]?token)\s*[:=]\s*['"]?)([A-Za-z0-9._~+/:=-]{12,})(['"]?)/gi, '$1[REDACTED]$3');
136
+ return out;
137
+ }
138
+
139
+ /**
140
+ * Extract useful metadata from a start-command isolation log. The logs commonly
141
+ * contain the original command, the temporary worktree path, and the command
142
+ * that launches the selected agent.
143
+ *
144
+ * @param {{logPath?: string|null, text?: string|null, sessionId?: string|null}} input
145
+ * @returns {{sessionId: string|null, command: string|null, taskUrl: string|null, workspace: string|null, tool: string|null, logPath: string|null}}
146
+ */
147
+ export function parseStartCommandLogMetadata(input = {}) {
148
+ const logPath = input.logPath || null;
149
+ const text = String(input.text || '');
150
+ const command = text.match(/^Command:\s*(.+)$/im)?.[1]?.trim() || null;
151
+ const sessionId = input.sessionId || (logPath ? path.basename(logPath).replace(/\.[^.]+$/, '') : null) || null;
152
+
153
+ const taskUrl = firstTaskUrl(command) || firstTaskUrl(text);
154
+ const workspace = extractWorkspace(text);
155
+ const tool = detectTool(command, text);
156
+
157
+ return {
158
+ sessionId,
159
+ command: command ? redactProcessText(command) : null,
160
+ taskUrl,
161
+ workspace,
162
+ tool,
163
+ logPath,
164
+ };
165
+ }
166
+
167
+ function normalizeSession(input) {
168
+ const command = input?.command ? String(input.command) : null;
169
+ const status = input?.status ? String(input.status).toLowerCase() : null;
170
+ const processIds = normalizeProcessIds(input?.processIds);
171
+ const sessionId = input?.sessionId || input?.uuid || input?.id || null;
172
+ const sessionName = input?.sessionName || input?.screenSessionName || sessionId || null;
173
+ const taskUrl = input?.taskUrl || firstTaskUrl(command);
174
+ const live = input?.live === true || RUNNING_STATUSES.has(status);
175
+
176
+ return {
177
+ ...input,
178
+ sessionId,
179
+ uuid: input?.uuid || sessionId,
180
+ sessionName,
181
+ screenSessionName: input?.screenSessionName || sessionName,
182
+ status,
183
+ command,
184
+ taskUrl,
185
+ workspace: normalizePath(input?.workspace || input?.workingDirectory || null),
186
+ tool: input?.tool ? String(input.tool).toLowerCase() : detectTool(command, command),
187
+ logPath: input?.logPath || null,
188
+ processIds,
189
+ live,
190
+ };
191
+ }
192
+
193
+ function normalizeProcess(input) {
194
+ return {
195
+ ...input,
196
+ pid: toPositiveInteger(input?.pid),
197
+ ppid: toPositiveInteger(input?.ppid),
198
+ pgid: toPositiveInteger(input?.pgid) || null,
199
+ sid: toPositiveInteger(input?.sid) || null,
200
+ state: input?.state || null,
201
+ commandName: input?.commandName || null,
202
+ cmdline: normalizeWhitespace(input?.cmdline || input?.command || ''),
203
+ cwd: normalizePath(input?.cwd || null),
204
+ exe: input?.exe || null,
205
+ screenSessionName: input?.screenSessionName || null,
206
+ };
207
+ }
208
+
209
+ function scoreSessionMatch(processRecord, session) {
210
+ const reasons = [];
211
+ const processIdValues = new Set(Object.values(session.processIds || {}).filter(Boolean));
212
+
213
+ if (processIdValues.has(processRecord.pid)) reasons.push('pid-session-process');
214
+ if (processIdValues.has(processRecord.ppid)) reasons.push('parent-session-process');
215
+ if (processIdValues.has(processRecord.pgid)) reasons.push('process-group-session-process');
216
+ if (processIdValues.has(processRecord.sid)) reasons.push('session-id-session-process');
217
+
218
+ if (processRecord.screenSessionName) {
219
+ const names = new Set([session.screenSessionName, session.sessionName, session.sessionId].filter(Boolean));
220
+ if (names.has(processRecord.screenSessionName)) reasons.push('screen-session');
221
+ }
222
+
223
+ if (session.workspace && processRecord.cwd && isPathInside(processRecord.cwd, session.workspace)) {
224
+ reasons.push('cwd-workspace');
225
+ }
226
+ if (session.workspace && containsToken(processRecord.cmdline, session.workspace)) {
227
+ reasons.push('cmd-workspace');
228
+ }
229
+ if (session.taskUrl && containsToken(processRecord.cmdline, session.taskUrl)) {
230
+ reasons.push('cmd-task-url');
231
+ }
232
+
233
+ const agentKind = detectAgentKind(processRecord);
234
+ if (agentKind && session.tool && agentKind === session.tool) reasons.push('agent-tool');
235
+ if (reasons.length === 1 && reasons[0] === 'agent-tool') {
236
+ return { score: 0, reasons: [] };
237
+ }
238
+
239
+ const weights = {
240
+ 'pid-session-process': 100,
241
+ 'parent-session-process': 90,
242
+ 'process-group-session-process': 80,
243
+ 'session-id-session-process': 80,
244
+ 'screen-session': 75,
245
+ 'cwd-workspace': 60,
246
+ 'cmd-workspace': 50,
247
+ 'cmd-task-url': 45,
248
+ 'agent-tool': 10,
249
+ };
250
+ const score = reasons.reduce((sum, reason) => sum + (weights[reason] || 1), 0);
251
+ return { score, reasons };
252
+ }
253
+
254
+ function chooseSession(processRecord, sessions) {
255
+ let best = null;
256
+ for (const session of sessions) {
257
+ const match = scoreSessionMatch(processRecord, session);
258
+ if (match.score <= 0) continue;
259
+ if (!best || match.score > best.score) {
260
+ best = { session, score: match.score, reasons: match.reasons };
261
+ }
262
+ }
263
+ return best;
264
+ }
265
+
266
+ function isOrphanedAgent(processRecord, session, agentKind, currentPid) {
267
+ if (!agentKind || !session || processRecord.pid === currentPid) return false;
268
+ const status = String(session.status || '').toLowerCase();
269
+ const terminal = TERMINAL_STATUSES.has(status);
270
+ if (!terminal) return false;
271
+ if (session.live) return false;
272
+ return processRecord.ppid === 1 || processRecord.ppid == null;
273
+ }
274
+
275
+ /**
276
+ * Correlate process records with start-command session/task records.
277
+ *
278
+ * @param {{processes: Array, sessions: Array, currentPid?: number|null}} input
279
+ * @returns {{items: Array, orphans: Array, sessions: Array}}
280
+ */
281
+ export function correlateProcesses(input = {}) {
282
+ const sessions = (input.sessions || []).map(normalizeSession).filter(session => session.sessionId || session.taskUrl || session.workspace);
283
+ const currentPid = toPositiveInteger(input.currentPid) || null;
284
+ const targetPids = new Set((input.targetPids || []).map(toPositiveInteger).filter(Boolean));
285
+ const items = [];
286
+
287
+ for (const rawProcess of input.processes || []) {
288
+ const processRecord = normalizeProcess(rawProcess);
289
+ if (!processRecord.pid) continue;
290
+
291
+ const agentKind = detectAgentKind(processRecord);
292
+ const match = chooseSession(processRecord, sessions);
293
+ const targeted = targetPids.has(processRecord.pid);
294
+ const strongMatch = match?.reasons?.some(reason => !['cwd-workspace', 'cmd-workspace', 'agent-tool'].includes(reason));
295
+ if (!agentKind && !targeted && !strongMatch) continue;
296
+
297
+ const session = match?.session || null;
298
+ const orphaned = isOrphanedAgent(processRecord, session, agentKind, currentPid);
299
+ items.push({
300
+ ...processRecord,
301
+ agentKind,
302
+ sessionId: session?.sessionId || null,
303
+ sessionName: session?.sessionName || null,
304
+ screenSessionName: processRecord.screenSessionName || session?.screenSessionName || null,
305
+ sessionStatus: session?.status || null,
306
+ sessionLive: session?.live === true,
307
+ taskUrl: session?.taskUrl || firstTaskUrl(processRecord.cmdline),
308
+ workspace: session?.workspace || processRecord.cwd || null,
309
+ logPath: session?.logPath || null,
310
+ matchReasons: match?.reasons || [],
311
+ orphaned,
312
+ });
313
+ }
314
+
315
+ items.sort((a, b) => {
316
+ if (a.orphaned !== b.orphaned) return a.orphaned ? -1 : 1;
317
+ return a.pid - b.pid;
318
+ });
319
+
320
+ return {
321
+ items,
322
+ orphans: items.filter(item => item.orphaned),
323
+ sessions,
324
+ };
325
+ }
326
+
327
+ function yesNo(value) {
328
+ return value ? 'yes' : 'no';
329
+ }
330
+
331
+ /**
332
+ * Render a console-safe diagnostic report. All command lines are redacted.
333
+ *
334
+ * @param {{items?: Array, orphans?: Array}} report
335
+ * @returns {string}
336
+ */
337
+ export function formatProcessDebugReport(report = {}) {
338
+ const items = report.items || [];
339
+ const orphans = report.orphans || [];
340
+ const lines = ['Process debug report', '====================', `Matched processes: ${items.length}`, `Orphaned terminal-session agents: ${orphans.length}`];
341
+
342
+ if (items.length === 0) {
343
+ lines.push('', 'No claude/codex/gemini/qwen/opencode processes were linked to hive-mind tasks.');
344
+ return lines.join('\n');
345
+ }
346
+
347
+ for (const item of items) {
348
+ lines.push('');
349
+ lines.push(`PID ${item.pid} ppid=${item.ppid ?? '?'} state=${item.state || '?'} agent=${item.agentKind || 'unknown'} orphan=${yesNo(item.orphaned)}`);
350
+ if (item.sessionId || item.sessionStatus) {
351
+ lines.push(` session: ${item.sessionId || '(unknown)'} status=${item.sessionStatus || '?'} live=${yesNo(item.sessionLive)}`);
352
+ }
353
+ if (item.taskUrl) lines.push(` task: ${item.taskUrl}`);
354
+ if (item.workspace) lines.push(` workspace: ${item.workspace}`);
355
+ if (item.logPath) lines.push(` log: ${item.logPath}`);
356
+ if (item.matchReasons?.length) lines.push(` match: ${item.matchReasons.join(', ')}`);
357
+ if (item.cmdline) lines.push(` cmd: ${redactProcessText(item.cmdline)}`);
358
+ }
359
+
360
+ return lines.join('\n');
361
+ }
@@ -405,6 +405,15 @@ export const SOLVE_OPTION_DEFINITIONS = {
405
405
  description: '[EXPERIMENTAL] Post tool output as PR comments in real-time. Supported for --tool claude and --tool codex.',
406
406
  default: false,
407
407
  },
408
+ // Issue #1843: render images that Claude/Codex read/write inline in the PR
409
+ // comments interactive mode posts. Images are committed to hidden custom Git
410
+ // refs and embedded via commit-SHA ?raw=true blob URLs (GitHub strips data: URIs).
411
+ // Disable with --no-interactive-image-upload to fall back to a metadata note.
412
+ 'interactive-image-upload': {
413
+ type: 'boolean',
414
+ description: '[EXPERIMENTAL] When --interactive-mode is on, upload images read/written by the AI to hidden custom Git refs (refs/hive-mind-media/...) and embed them inline in PR comments. Enabled by default; use --no-interactive-image-upload to disable.',
415
+ default: true,
416
+ },
408
417
  // Issue #817: Bidirectional interactive options
409
418
  'accept-incomming-comments-as-input': {
410
419
  type: 'boolean',