@miraj181/ipingyou 2.0.13 → 2.1.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,627 @@
1
+ /**
2
+ * AI Mode — privacy-first local/remote task assistant powered by Groq.
3
+ */
4
+
5
+ import { execa, execaCommand } from 'execa';
6
+ import chalk from 'chalk';
7
+ import inquirer from 'inquirer';
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { getAlias } from '../lib/config.js';
12
+ import { resolveUID } from '../lib/broker.js';
13
+ import { buildSshArgs, extractHostname } from '../lib/ssh.js';
14
+ import { addCleanupHook, cleanupAll } from '../lib/cleanup.js';
15
+ import { startHostMode } from './host.js';
16
+ import { startClientMode } from './client.js';
17
+ import { performSCPNonInteractive } from './client.js';
18
+ import { DEFAULT_AI_MODEL, createGroqChatCompletion, getGroqApiKey, getRateLimitWarnings, listGroqModels, estimateTokensForMessages } from '../lib/ai/groq.js';
19
+ import { classifyCommand, redactSensitive, sanitizeUserTask, truncateForModel } from '../lib/ai/safety.js';
20
+ import { recordEvent } from '../lib/session-log.js';
21
+
22
+ let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
23
+
24
+ const SYSTEM_PROMPT = `You are iPingYou AI Mode, a careful terminal task assistant.
25
+ You can request local tools. You must protect user secrets.
26
+ Never ask to read private keys, .env files, token stores, ~/.ssh, ~/.ipingyou, or password/config files.
27
+ Never include secrets in your final answer. Prefer read-only inspection before changes.
28
+ For commands, request the smallest safe command needed.
29
+ When you need a command, call a tool. When finished, answer concisely with what happened and next steps.`;
30
+
31
+ const TOOLS = [
32
+ {
33
+ type: 'function',
34
+ function: {
35
+ name: 'run_command',
36
+ description: 'Run a shell command in the selected AI session scope. In remote mode this runs on the remote host via SSH.',
37
+ parameters: {
38
+ type: 'object',
39
+ additionalProperties: false,
40
+ properties: {
41
+ command: { type: 'string', description: 'The command to execute.' },
42
+ reason: { type: 'string', description: 'Short reason for running this command.' },
43
+ },
44
+ required: ['command', 'reason'],
45
+ },
46
+ },
47
+ },
48
+ {
49
+ type: 'function',
50
+ function: {
51
+ name: 'read_text_file',
52
+ description: 'Read a non-secret text file from the selected session scope.',
53
+ parameters: {
54
+ type: 'object',
55
+ additionalProperties: false,
56
+ properties: {
57
+ filePath: { type: 'string', description: 'Path to a non-secret text file.' },
58
+ },
59
+ required: ['filePath'],
60
+ },
61
+ },
62
+ },
63
+ ];
64
+
65
+ const APP_ACTIONS = [
66
+ {
67
+ id: 'host',
68
+ label: 'Start host mode',
69
+ description: 'Expose this machine through iPingYou host mode.',
70
+ pattern: /\b(host|allow remote access|share this machine|expose this machine|start server)\b/i,
71
+ run: async () => startHostMode(),
72
+ },
73
+ {
74
+ id: 'connect',
75
+ label: 'Access a remote machine',
76
+ description: 'Run the normal iPingYou connect flow for SSH/SCP/chat/reverse tunnel.',
77
+ pattern: /\b(connect|access remote|remote machine|ssh|scp|upload|download|file transfer|join chat|reverse tunnel)\b/i,
78
+ run: async () => startClientMode(),
79
+ },
80
+ {
81
+ id: 'help',
82
+ label: 'Show AI mode help',
83
+ description: 'Explain what AI mode can do safely.',
84
+ pattern: /\b(help|what can you do|ai mode|how does this work)\b/i,
85
+ run: async () => {
86
+ console.log('');
87
+ console.log(chalk.bold.cyan(' AI Mode Help'));
88
+ console.log(chalk.dim(' ─────────────────────────────────────'));
89
+ console.log(' • Ask local/remote task questions and AI can inspect with guarded commands.');
90
+ console.log(' • Ask for normal app actions like host/connect/upload/download and AI will offer to launch that flow.');
91
+ console.log(' • Secrets, decrypted tunnel URLs, private keys, .env, ~/.ssh, and ~/.ipingyou are blocked/redacted.');
92
+ console.log(' • Risky commands require confirmation before execution.');
93
+ console.log('');
94
+ },
95
+ },
96
+ ];
97
+
98
+ function normalizePrivateKey(privateKey) {
99
+ const normalized = String(privateKey || '').replace(/\\n/g, '\n').replace(/\r\n/g, '\n');
100
+ return normalized.endsWith('\n') ? normalized : `${normalized}\n`;
101
+ }
102
+
103
+ async function writeEphemeralPrivateKey(privateKey) {
104
+ const keyPath = path.join(os.tmpdir(), `ipingyou_ai_${Date.now()}`);
105
+ fs.writeFileSync(keyPath, normalizePrivateKey(privateKey), { mode: 0o600 });
106
+
107
+ const result = await execa('ssh-keygen', ['-y', '-f', keyPath], {
108
+ reject: false,
109
+ stdio: ['ignore', 'pipe', 'pipe'],
110
+ });
111
+
112
+ if (result.exitCode !== 0) {
113
+ try { fs.unlinkSync(keyPath); } catch { }
114
+ throw new Error(result.stderr.trim() || 'OpenSSH could not parse the host-provided private key');
115
+ }
116
+
117
+ addCleanupHook(() => {
118
+ try { fs.unlinkSync(keyPath); } catch { }
119
+ });
120
+
121
+ return keyPath;
122
+ }
123
+
124
+ function parseToolArgs(raw) {
125
+ try {
126
+ return JSON.parse(raw || '{}');
127
+ } catch {
128
+ return {};
129
+ }
130
+ }
131
+
132
+ function buildToolResult(result) {
133
+ return truncateForModel(JSON.stringify(result, null, 2), 12000);
134
+ }
135
+
136
+ async function confirmCommand(scope, command, reason, classification) {
137
+ if (classification.blocked) {
138
+ return false;
139
+ }
140
+
141
+ if (!classification.needsApproval) {
142
+ console.log(chalk.dim(` AI tool: ${scope} $ ${command}`));
143
+ return true;
144
+ }
145
+
146
+ console.log('');
147
+ console.log(chalk.yellow(' AI wants to run a command that needs approval:'));
148
+ console.log(chalk.dim(` Scope: ${scope}`));
149
+ console.log(chalk.dim(` Reason: ${reason || classification.reason}`));
150
+ console.log(chalk.cyan(` ${command}`));
151
+
152
+ const { allow } = await inquirer.prompt([{
153
+ type: 'confirm',
154
+ name: 'allow',
155
+ message: 'Allow this command?',
156
+ default: false,
157
+ }]);
158
+
159
+ return allow;
160
+ }
161
+
162
+ function matchAppAction(task) {
163
+ const lowered = String(task || '').toLowerCase();
164
+ if (/\b(panic|self[- ]?destruct|wipe traces)\b/i.test(lowered)) {
165
+ return {
166
+ id: 'panic_blocked',
167
+ label: 'Panic mode',
168
+ description: 'Panic mode is intentionally not launched from AI mode. Run `ipingyou panic` directly if you mean it.',
169
+ blocked: true,
170
+ };
171
+ }
172
+
173
+ return APP_ACTIONS.find(action => action.pattern.test(task)) || null;
174
+ }
175
+
176
+ async function maybeRunMatchedAppAction(task) {
177
+ const action = matchAppAction(task);
178
+ if (!action) return false;
179
+
180
+ console.log('');
181
+ console.log(chalk.cyan(` I matched this to an iPingYou function: ${action.label}`));
182
+ console.log(chalk.dim(` ${action.description}`));
183
+
184
+ if (action.blocked) {
185
+ console.log(chalk.yellow(' This function is not executed from AI mode for safety.'));
186
+ return true;
187
+ }
188
+
189
+ const { runIt } = await inquirer.prompt([{
190
+ type: 'confirm',
191
+ name: 'runIt',
192
+ message: 'Do you want me to run this app function now?',
193
+ default: true,
194
+ }]);
195
+
196
+ if (!runIt) return false;
197
+ await action.run();
198
+ return true;
199
+ }
200
+
201
+ function showRateLimitWarnings(rateLimit) {
202
+ const warnings = getRateLimitWarnings(rateLimit, 0.8);
203
+ for (const warning of warnings) {
204
+ console.log(chalk.yellow(
205
+ ` ⚠️ Groq ${warning.label} limit is ${warning.percent}% used ` +
206
+ `(${warning.remaining}/${warning.limit} remaining${warning.reset ? `, resets in ${warning.reset}` : ''}).`
207
+ ));
208
+ console.log(chalk.dim(' Consider switching API key/model or pausing before the key hits its limit.'));
209
+ }
210
+ }
211
+
212
+ async function runLocalCommand(command) {
213
+ const result = await execaCommand(command, {
214
+ shell: true,
215
+ reject: false,
216
+ timeout: 30000,
217
+ maxBuffer: 1024 * 1024,
218
+ });
219
+
220
+ return {
221
+ exitCode: result.exitCode,
222
+ stdout: redactSensitive(result.stdout || ''),
223
+ stderr: redactSensitive(result.stderr || ''),
224
+ };
225
+ }
226
+
227
+ async function runRemoteCommand(context, command) {
228
+ const sshArgs = buildSshArgs(context.hostname, context.privateKeyPath);
229
+ sshArgs.push(`${context.username}@${context.hostname}`, command);
230
+ const result = await execa('ssh', sshArgs, {
231
+ reject: false,
232
+ timeout: 30000,
233
+ maxBuffer: 1024 * 1024,
234
+ });
235
+
236
+ return {
237
+ exitCode: result.exitCode,
238
+ stdout: redactSensitive(result.stdout || ''),
239
+ stderr: redactSensitive(result.stderr || ''),
240
+ };
241
+ }
242
+
243
+ function assertReadablePath(filePath) {
244
+ const classification = classifyCommand(`cat ${filePath}`);
245
+ if (classification.blocked) {
246
+ throw new Error(classification.reason);
247
+ }
248
+ }
249
+
250
+ async function readLocalTextFile(filePath) {
251
+ assertReadablePath(filePath);
252
+ const stat = fs.statSync(filePath);
253
+ if (!stat.isFile()) throw new Error('Path is not a file');
254
+ if (stat.size > 256 * 1024) throw new Error('File is too large for AI mode; use a targeted command instead');
255
+ return redactSensitive(fs.readFileSync(filePath, 'utf8'));
256
+ }
257
+
258
+ async function readRemoteTextFile(context, filePath) {
259
+ assertReadablePath(filePath);
260
+ return runRemoteCommand(context, `python3 - <<'PY'\nfrom pathlib import Path\np=Path(${JSON.stringify(filePath)})\nif not p.is_file(): raise SystemExit('Path is not a file')\nif p.stat().st_size > 262144: raise SystemExit('File is too large for AI mode')\nprint(p.read_text(errors='replace'), end='')\nPY`);
261
+ }
262
+
263
+ async function setupRemoteContext() {
264
+ const { targetMode } = await inquirer.prompt([{
265
+ type: 'list',
266
+ name: 'targetMode',
267
+ message: 'How should AI connect to the remote host?',
268
+ choices: [
269
+ { name: 'Enter UID/session password', value: 'manual' },
270
+ { name: 'Use saved alias', value: 'alias' },
271
+ ],
272
+ }]);
273
+
274
+ let uid;
275
+ let password;
276
+ let username;
277
+
278
+ if (targetMode === 'alias') {
279
+ const { aliasName } = await inquirer.prompt([{
280
+ type: 'input',
281
+ name: 'aliasName',
282
+ message: 'Alias name:',
283
+ validate: v => v.trim().length > 0 || 'Required',
284
+ }]);
285
+ const alias = getAlias(aliasName.trim());
286
+ if (!alias) throw new Error(`Alias not found: ${aliasName.trim()}`);
287
+ uid = alias.uid;
288
+ password = alias.password;
289
+ username = alias.username;
290
+ } else {
291
+ const answers = await inquirer.prompt([
292
+ { type: 'input', name: 'uid', message: 'Remote host UID:', validate: v => v.trim().length > 0 || 'Required' },
293
+ { type: 'password', name: 'password', message: 'Session password:', mask: '*' },
294
+ { type: 'input', name: 'username', message: 'SSH username:', default: process.env.USER || process.env.USERNAME || 'root' },
295
+ ]);
296
+ uid = answers.uid.trim();
297
+ password = answers.password;
298
+ username = answers.username.trim();
299
+ }
300
+
301
+ const payload = await resolveUID(BROKER_URL, uid, password);
302
+ if (!payload || payload.type !== 'ssh') {
303
+ throw new Error('AI remote mode requires an active SSH host session');
304
+ }
305
+
306
+ let privateKeyPath = null;
307
+ if (payload.privateKey) {
308
+ privateKeyPath = await writeEphemeralPrivateKey(payload.privateKey);
309
+ }
310
+
311
+ return {
312
+ scope: 'remote',
313
+ username,
314
+ hostname: extractHostname(payload.url),
315
+ privateKeyPath,
316
+ sharedDropPath: payload.sharedDropPath || null,
317
+ };
318
+ }
319
+
320
+ async function chooseModel(apiKey) {
321
+ let models = [];
322
+ try {
323
+ models = await listGroqModels(apiKey);
324
+ } catch (err) {
325
+ console.log(chalk.yellow(` ⚠️ Could not fetch Groq model list: ${err.message}`));
326
+ }
327
+
328
+ const ids = models.map(model => model.id).filter(Boolean);
329
+ const preferred = [
330
+ 'qwen/qwen3-32b',
331
+ 'openai/gpt-oss-120b',
332
+ 'llama-3.3-70b-versatile',
333
+ 'groq/compound',
334
+ 'groq/compound-mini',
335
+ ].filter(id => ids.length === 0 || ids.includes(id));
336
+
337
+ const choices = [...new Set([...preferred, DEFAULT_AI_MODEL])].map(id => ({ name: id, value: id }));
338
+ choices.push({ name: 'Type model manually', value: 'manual' });
339
+
340
+ const { modelChoice } = await inquirer.prompt([{
341
+ type: 'list',
342
+ name: 'modelChoice',
343
+ message: 'Which Groq model should AI mode use?',
344
+ choices,
345
+ }]);
346
+
347
+ if (modelChoice !== 'manual') return modelChoice;
348
+
349
+ const { manualModel } = await inquirer.prompt([{
350
+ type: 'input',
351
+ name: 'manualModel',
352
+ message: 'Groq model id:',
353
+ default: DEFAULT_AI_MODEL,
354
+ validate: v => v.trim().length > 0 || 'Required',
355
+ }]);
356
+ return manualModel.trim();
357
+ }
358
+
359
+ async function executeToolCall(context, call) {
360
+ const name = call.function?.name;
361
+ const args = parseToolArgs(call.function?.arguments);
362
+
363
+ if (name === 'run_command') {
364
+ const command = String(args.command || '').trim();
365
+ const reason = String(args.reason || '').trim();
366
+ const classification = classifyCommand(command);
367
+ if (classification.blocked) {
368
+ return buildToolResult({ ok: false, blocked: true, error: classification.reason });
369
+ }
370
+
371
+ const approved = await confirmCommand(context.scope, command, reason, classification);
372
+ if (!approved) {
373
+ return buildToolResult({ ok: false, blocked: true, error: 'User denied command execution' });
374
+ }
375
+
376
+ const result = context.scope === 'remote'
377
+ ? await runRemoteCommand(context, command)
378
+ : await runLocalCommand(command);
379
+
380
+ recordEvent('ai_command_executed', { scope: context.scope, command, approved: true, exitCode: result.exitCode });
381
+
382
+ return buildToolResult({ ok: result.exitCode === 0, ...result });
383
+ }
384
+
385
+ if (name === 'read_text_file') {
386
+ const filePath = String(args.filePath || '').trim();
387
+ if (!filePath) return buildToolResult({ ok: false, error: 'Missing filePath' });
388
+
389
+ if (context.scope === 'remote') {
390
+ const result = await readRemoteTextFile(context, filePath);
391
+ return buildToolResult({ ok: result.exitCode === 0, content: result.stdout, stderr: result.stderr });
392
+ }
393
+
394
+ const content = await readLocalTextFile(filePath);
395
+ return buildToolResult({ ok: true, content: truncateForModel(content, 12000) });
396
+ }
397
+
398
+ return buildToolResult({ ok: false, error: `Unknown tool: ${name}` });
399
+ }
400
+
401
+ async function runAgentTurn(apiKey, model, context, messages, task) {
402
+ messages.push({ role: 'user', content: sanitizeUserTask(task) });
403
+ let sessionTokens = 0;
404
+
405
+ for (let step = 0; step < 8; step++) {
406
+ let completion;
407
+ try {
408
+ completion = await createGroqChatCompletion(apiKey, {
409
+ model,
410
+ messages,
411
+ tools: TOOLS,
412
+ tool_choice: 'auto',
413
+ temperature: 0.2,
414
+ max_completion_tokens: 2048,
415
+ });
416
+ } catch (err) {
417
+ showRateLimitWarnings(err.rateLimit);
418
+ if (err.status === 429) {
419
+ console.log(chalk.yellow(' ⚠️ Groq rate limit reached for this API key/model.'));
420
+ console.log(chalk.dim(' Switch API key/model or wait for the reset window before continuing.'));
421
+ return;
422
+ }
423
+ throw err;
424
+ }
425
+ showRateLimitWarnings(completion._rateLimit);
426
+
427
+ const message = completion.choices?.[0]?.message;
428
+ if (!message) throw new Error('Groq returned an empty response');
429
+
430
+ // Estimate token usage for this turn and record it
431
+ try {
432
+ const est = estimateTokensForMessages(messages.map(m => m.content || ''), message.content || '');
433
+ sessionTokens += est;
434
+ recordEvent('ai_tokens_used', { tokens: est, cumulative: sessionTokens });
435
+ const pricePerToken = Number(process.env.GROQ_PRICE_PER_TOKEN) || 0.00001;
436
+ recordEvent('ai_cost_estimate', { tokens: sessionTokens, cost: +(sessionTokens * pricePerToken).toFixed(6), currency: 'USD' });
437
+ } catch {
438
+ // best-effort only
439
+ }
440
+
441
+ messages.push(message);
442
+
443
+ if (!message.tool_calls || message.tool_calls.length === 0) {
444
+ console.log('');
445
+ console.log(chalk.green(' AI: ') + redactSensitive(message.content || 'Done.'));
446
+ return;
447
+ }
448
+
449
+ for (const call of message.tool_calls) {
450
+ try {
451
+ const content = await executeToolCall(context, call);
452
+ messages.push({ role: 'tool', tool_call_id: call.id, content });
453
+ } catch (err) {
454
+ messages.push({
455
+ role: 'tool',
456
+ tool_call_id: call.id,
457
+ content: buildToolResult({ ok: false, error: redactSensitive(err.message) }),
458
+ });
459
+ }
460
+ }
461
+ }
462
+
463
+ console.log(chalk.yellow(' ⚠️ AI reached the per-task tool limit. Ask it to continue if needed.'));
464
+ }
465
+
466
+ export async function startAIMode() {
467
+ console.log('');
468
+ console.log(chalk.bold.cyan(' 🤖 AI MODE — Task Assistant'));
469
+ console.log(chalk.dim(' ─────────────────────────────────────'));
470
+ console.log(chalk.dim(' Privacy: API keys, private keys, session passwords, tunnel URLs, ~/.ssh, ~/.ipingyou, and .env files are blocked/redacted.'));
471
+ console.log(chalk.dim(' Note: your task text and sanitized tool results are sent to Groq for inference.'));
472
+ console.log('');
473
+
474
+ const { consent } = await inquirer.prompt([{
475
+ type: 'confirm',
476
+ name: 'consent',
477
+ message: 'Continue with Groq AI mode under these privacy rules?',
478
+ default: true,
479
+ }]);
480
+ if (!consent) return;
481
+
482
+ let apiKey = getGroqApiKey();
483
+ if (!apiKey) {
484
+ const { enteredKey } = await inquirer.prompt([{
485
+ type: 'password',
486
+ name: 'enteredKey',
487
+ message: 'Groq API key (stored only in memory for this session):',
488
+ mask: '*',
489
+ validate: v => v.trim().length > 0 || 'Required',
490
+ }]);
491
+ apiKey = enteredKey.trim();
492
+ }
493
+
494
+ const model = await chooseModel(apiKey);
495
+
496
+ const { scope } = await inquirer.prompt([{
497
+ type: 'list',
498
+ name: 'scope',
499
+ message: 'Where should AI mode work?',
500
+ choices: [
501
+ { name: 'Local machine', value: 'local' },
502
+ { name: 'Remote machine through iPingYou SSH session', value: 'remote' },
503
+ ],
504
+ }]);
505
+
506
+ const context = scope === 'remote'
507
+ ? await setupRemoteContext()
508
+ : { scope: 'local' };
509
+
510
+ const messages = [
511
+ { role: 'system', content: SYSTEM_PROMPT },
512
+ {
513
+ role: 'system',
514
+ content: `Session scope: ${context.scope}. Current working directory: ${process.cwd()}. Remote details and secrets are intentionally hidden from you.`,
515
+ },
516
+ ];
517
+
518
+ console.log('');
519
+ console.log(chalk.green(` ✓ AI mode ready with ${model}. Type a task, or type exit to quit.`));
520
+ console.log(chalk.dim(' Press Ctrl+C any time to terminate the session.'));
521
+
522
+ while (true) {
523
+ const { task } = await inquirer.prompt([{
524
+ type: 'input',
525
+ name: 'task',
526
+ message: 'AI task:',
527
+ validate: v => v.trim().length > 0 || 'Required',
528
+ }]);
529
+
530
+ const trimmed = task.trim();
531
+ if (/^(exit|quit|q)$/i.test(trimmed)) break;
532
+ // Try AI Transfer Assistant parsing first (upload/download automation)
533
+ if (await tryAITransfer(trimmed, context)) continue;
534
+ if (await maybeRunMatchedAppAction(trimmed)) continue;
535
+ await runAgentTurn(apiKey, model, context, messages, trimmed);
536
+ }
537
+
538
+ await cleanupAll();
539
+ }
540
+
541
+ async function tryAITransfer(task, context) {
542
+ const uploadRegex = /\b(?:upload|send|scp)\b\s+(.+?)\s+(?:to|->|into)\s+(.+)$/i;
543
+ const downloadRegex = /\b(?:download|get|fetch|scp)\b\s+(.+?)\s+(?:to|into|->)\s+(.+)$/i;
544
+ let m = task.match(uploadRegex);
545
+ let direction = null;
546
+ let src = null;
547
+ let dst = null;
548
+ if (m) {
549
+ direction = 'upload'; src = m[1].trim(); dst = m[2].trim();
550
+ } else {
551
+ m = task.match(downloadRegex);
552
+ if (m) { direction = 'download'; src = m[1].trim(); dst = m[2].trim(); }
553
+ }
554
+
555
+ if (!direction) return false;
556
+
557
+ console.log('');
558
+ console.log(chalk.cyan(' AI Transfer Assistant: detected a transfer intent.'));
559
+
560
+ let localPath = direction === 'upload' ? src : dst;
561
+ let remotePath = direction === 'upload' ? dst : src;
562
+
563
+ // Prevent SCP argument injection (e.g. if path starts with -oProxyCommand=...)
564
+ if (localPath.startsWith('-')) localPath = './' + localPath;
565
+ if (remotePath.startsWith('-')) remotePath = './' + remotePath;
566
+
567
+ // ─── Reuse existing remote context if available ─────────────
568
+ if (context && context.scope === 'remote' && context.hostname && context.username) {
569
+ console.log(chalk.dim(` Using active remote session: ${context.username}@${context.hostname}`));
570
+
571
+ const { getSshControlOptions, formatScpRemotePath } = await import('../lib/ssh.js');
572
+
573
+ const proxyCommand = `cloudflared access tcp --hostname ${context.hostname}`;
574
+ const scpArgs = [
575
+ '-r',
576
+ '-o', `ProxyCommand=${proxyCommand}`,
577
+ '-o', 'StrictHostKeyChecking=accept-new',
578
+ '-o', 'IdentitiesOnly=yes',
579
+ ...getSshControlOptions(context.hostname),
580
+ ];
581
+ if (context.privateKeyPath) {
582
+ scpArgs.push('-i', context.privateKeyPath, '-o', 'IdentityAgent=none');
583
+ }
584
+
585
+ const remoteSpec = `${context.username}@${context.hostname}:${formatScpRemotePath(remotePath)}`;
586
+ if (direction === 'upload') {
587
+ scpArgs.push(localPath, remoteSpec);
588
+ } else {
589
+ scpArgs.push(remoteSpec, localPath);
590
+ }
591
+
592
+ try {
593
+ const result = await execa('scp', scpArgs, { stdio: 'inherit', reject: false });
594
+ if (result.exitCode === 0) {
595
+ console.log(chalk.green(' ✅ Transfer completed via active remote session.'));
596
+ recordEvent('ai_transfer_success', { direction, localPath, remotePath, hostname: context.hostname, reusedContext: true });
597
+ } else {
598
+ console.log(chalk.red(` ❌ Transfer failed (exit code ${result.exitCode})`));
599
+ recordEvent('ai_transfer_failed', { direction, localPath, remotePath, hostname: context.hostname, reusedContext: true });
600
+ }
601
+ return true;
602
+ } catch (err) {
603
+ console.log(chalk.red(` ❌ Transfer error: ${err.message}`));
604
+ return true;
605
+ }
606
+ }
607
+
608
+ // ─── Fallback: prompt for UID/password (local scope) ────────
609
+ console.log(chalk.dim(' No active remote session — prompting for connection details.'));
610
+
611
+ const answers = await inquirer.prompt([
612
+ { type: 'input', name: 'uid', message: 'Target host UID:', validate: v => v.trim().length > 0 || 'Required' },
613
+ { type: 'password', name: 'password', message: 'Session password:', mask: '*', validate: v => v.trim().length > 0 || 'Required' },
614
+ { type: 'input', name: 'username', message: 'SSH username on host:', default: process.env.USER || process.env.USERNAME || 'root' }
615
+ ]);
616
+
617
+ try {
618
+ const ok = await performSCPNonInteractive({ brokerUrl: BROKER_URL, uid: answers.uid.trim(), password: answers.password.trim(), username: answers.username.trim(), direction, localPath, remotePath });
619
+ if (ok) console.log(chalk.green(' ✅ Automated transfer completed.'));
620
+ else console.log(chalk.red(' ❌ Automated transfer failed.'));
621
+ return true;
622
+ } catch (err) {
623
+ console.log(chalk.red(` ❌ Transfer error: ${err.message}`));
624
+ return true;
625
+ }
626
+ }
627
+