vigthoria-cli 1.10.0 → 1.10.36

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.
@@ -4,13 +4,14 @@ import * as os from 'os';
4
4
  import * as path from 'path';
5
5
  import * as readline from 'readline';
6
6
  import { createSpinner } from '../utils/logger.js';
7
- import { APIClient, CLIError, classifyError, formatCLIError, sanitizeUserFacingErrorText, sanitizeUserFacingPathText, propagateError } from '../utils/api.js';
7
+ import { APIClient, CLIError, classifyError, formatCLIError, sanitizeUserFacingErrorText, sanitizeUserFacingPathText, propagateError, VIGTHORIA_SERVER_TEMPORARILY_UNAVAILABLE_MESSAGE } from '../utils/api.js';
8
8
  import { AgenticTools, robustifyStreamResponse } from '../utils/tools.js';
9
9
  import { SessionManager } from '../utils/session.js';
10
10
  import { BridgeClient, getBridgeClient } from '../utils/bridge-client.js';
11
- import { WorkspaceWatcher } from '../utils/workspace-stream.js';
11
+ import { WorkspaceWatcher, WorkspaceWSClient } from '../utils/workspace-stream.js';
12
12
  import { TaskDisplay } from '../utils/task-display.js';
13
13
  import { ProjectMemoryService } from '../utils/project-memory.js';
14
+ import { buildPersonaOverlay, normalizePersonaMode } from '../utils/persona.js';
14
15
  const DEFAULT_V3_AGENT_TIMEOUT_MS = (() => {
15
16
  const rawValue = process.env.VIGTHORIA_AGENT_TIMEOUT_MS || process.env.V3_AGENT_TIMEOUT_MS;
16
17
  if (!rawValue) {
@@ -22,18 +23,18 @@ const DEFAULT_V3_AGENT_TIMEOUT_MS = (() => {
22
23
  const DEFAULT_V3_AGENT_IDLE_TIMEOUT_MS = (() => {
23
24
  const rawValue = process.env.VIGTHORIA_AGENT_IDLE_TIMEOUT_MS || process.env.V3_AGENT_IDLE_TIMEOUT_MS;
24
25
  if (!rawValue) {
25
- return 0;
26
+ return 90000;
26
27
  }
27
28
  const parsed = Number.parseInt(rawValue, 10);
28
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
29
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 90000;
29
30
  })();
30
31
  const DEFAULT_V3_AGENT_SOFT_TIMEOUT_MS = (() => {
31
32
  const rawValue = process.env.VIGTHORIA_AGENT_SOFT_TIMEOUT_MS || process.env.V3_AGENT_SOFT_TIMEOUT_MS;
32
33
  if (!rawValue) {
33
- return 0;
34
+ return 180000;
34
35
  }
35
36
  const parsed = Number.parseInt(rawValue, 10);
36
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
37
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 180000;
37
38
  })();
38
39
  export class ChatCommand {
39
40
  config;
@@ -52,6 +53,7 @@ export class ChatCommand {
52
53
  currentModel = 'code';
53
54
  modelExplicitlySelected = false;
54
55
  autoApprove = false;
56
+ personaOverride = null;
55
57
  // Phase 5: Agent quality gate — track tool usage for evidence thresholds
56
58
  agentToolEvidence = { discovery: 0, mutation: 0, searchFailed: 0 };
57
59
  operatorMode = false;
@@ -112,17 +114,22 @@ export class ChatCommand {
112
114
  toUserFacingApiError(error, context) {
113
115
  const classified = classifyError(error);
114
116
  const status = classified.statusCode || (this.isJwtExpirationError(error) ? 401 : 500);
115
- if (this.isJwtExpirationError(error)) {
117
+ if (this.isJwtExpirationError(error) || classified.category === 'auth') {
116
118
  return new CLIError('Your Vigthoria session has expired. Run `vigthoria login` to authenticate again.', 'auth', { statusCode: 401 });
117
119
  }
118
- if (this.isTimeoutError(error)) {
120
+ // Preserve structured API classification first (auth/model/network/etc.)
121
+ // so upstream responses are not relabeled by message heuristics.
122
+ if (classified.category === 'timeout') {
119
123
  return new CLIError(`${context} timed out. Check your connection and try again.`, 'timeout', { statusCode: status });
120
124
  }
121
- if (this.isNetworkError(error)) {
125
+ if (classified.category === 'network') {
122
126
  return new CLIError(`${context} could not reach the Vigthoria API. Check your network connection and try again.`, 'network', { statusCode: status });
123
127
  }
128
+ if (classified.category === 'model_backend') {
129
+ return new CLIError(VIGTHORIA_SERVER_TEMPORARILY_UNAVAILABLE_MESSAGE, 'model_backend', { statusCode: status, endpoint: classified.endpoint });
130
+ }
124
131
  const message = sanitizeUserFacingErrorText(classified.message || `${context} failed`);
125
- return new CLIError(message, status === 401 ? 'auth' : 'model_backend', { statusCode: status });
132
+ return new CLIError(message, 'model_backend', { statusCode: status, endpoint: classified.endpoint });
126
133
  }
127
134
  handleApiError(error, context) {
128
135
  const userFacingError = this.toUserFacingApiError(error, context);
@@ -151,10 +158,6 @@ export class ChatCommand {
151
158
  return await operation();
152
159
  }
153
160
  catch (error) {
154
- if (!this.jsonOutput) {
155
- const transient = this.isTimeoutError(error) ? 'timeout' : this.isNetworkError(error) ? 'network error' : 'API error';
156
- console.error(chalk.red(`${context} failed with ${transient}: ${this.toUserFacingApiError(error, context).message}`));
157
- }
158
161
  lastError = error;
159
162
  if (this.isJwtExpirationError(error)) {
160
163
  this.handleApiError(error, context);
@@ -163,7 +166,8 @@ export class ChatCommand {
163
166
  this.handleApiError(error, context);
164
167
  }
165
168
  if (!this.jsonOutput) {
166
- this.logger.warn(`${context} failed due to ${this.isTimeoutError(error) ? 'timeout' : 'network error'}; retrying (${attempt + 1}/${retries})...`);
169
+ const transient = this.isTimeoutError(error) ? 'timeout' : this.isNetworkError(error) ? 'network error' : 'temporary service issue';
170
+ this.logger.warn(`${context} failed due to ${transient}; retrying (${attempt + 1}/${retries})...`);
167
171
  }
168
172
  await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1)));
169
173
  }
@@ -262,7 +266,7 @@ export class ChatCommand {
262
266
  explicitModel: true,
263
267
  heavyTask,
264
268
  cloudEligible,
265
- cloudSelected: this.currentModel === 'cloud' || this.currentModel === 'cloud-reason' || this.currentModel === 'ultra',
269
+ cloudSelected: ['cloud', 'cloud-reason', 'ultra', 'cloud-fast', 'cloud-balanced', 'cloud-code', 'cloud-power', 'cloud-maximum'].includes(this.currentModel),
266
270
  routeReason: 'explicit-model-selection',
267
271
  };
268
272
  }
@@ -295,8 +299,19 @@ export class ChatCommand {
295
299
  routeReason: 'default-v3-agent',
296
300
  };
297
301
  }
298
- getMessagesForModel() {
302
+ getMessagesForModel(options) {
299
303
  const messages = [...this.messages];
304
+ const personaOverlay = this.buildActivePersonaOverlay();
305
+ if (personaOverlay && !messages.some((message) => message.role === 'system' && message.content.includes('Optional persona overlay: Wiener Grantler mode.'))) {
306
+ const insertionIndex = messages.findIndex((message) => message.role !== 'system');
307
+ const personaMessage = { role: 'system', content: personaOverlay };
308
+ if (insertionIndex === -1) {
309
+ messages.push(personaMessage);
310
+ }
311
+ else {
312
+ messages.splice(insertionIndex, 0, personaMessage);
313
+ }
314
+ }
300
315
  const memoryContexts = [
301
316
  this.sessionManager.buildMemoryContext(this.currentSession),
302
317
  this.projectMemory?.buildContextForPrompt(this.getLastUserPrompt()) || '',
@@ -318,8 +333,137 @@ export class ChatCommand {
318
333
  messages.splice(insertionIndex, 0, memoryMessage);
319
334
  }
320
335
  }
336
+ if (options?.compact) {
337
+ const compactLimit = 6000;
338
+ const systemMessages = messages.filter((message) => message.role === 'system').map((message) => ({
339
+ ...message,
340
+ content: message.content.length > compactLimit
341
+ ? `${message.content.slice(0, compactLimit)}\n...[trimmed for first local agent turn]`
342
+ : message.content,
343
+ }));
344
+ const lastUser = [...messages].reverse().find((message) => message.role === 'user');
345
+ return lastUser ? [...systemMessages.slice(0, 2), lastUser] : systemMessages.slice(0, 2);
346
+ }
321
347
  return messages;
322
348
  }
349
+ normalizeClientV3ToolPath(rawPath) {
350
+ let normalized = String(rawPath || '.').trim().replace(/\\/g, '/');
351
+ if (!normalized || normalized === '/') {
352
+ return '.';
353
+ }
354
+ normalized = normalized.replace(/^vigthoria:\/\/workspace\/?/i, '');
355
+ normalized = normalized.replace(/^\.\/+/, '');
356
+ normalized = normalized.replace(/^workspace\/?/i, '');
357
+ const workspaceRoot = this.currentProjectPath || process.cwd();
358
+ const workspaceName = path.basename(workspaceRoot);
359
+ if (workspaceName) {
360
+ const workspaceKey = workspaceName.toLowerCase().replace(/[\s_./\\-]+/g, '');
361
+ const parts = normalized.split('/').filter(Boolean);
362
+ if (parts.length > 0) {
363
+ const firstKey = parts[0].toLowerCase().replace(/[\s_./\\-]+/g, '');
364
+ if (firstKey === workspaceKey) {
365
+ normalized = parts.slice(1).join('/') || '.';
366
+ }
367
+ }
368
+ }
369
+ normalized = normalized.replace(/^\/+/, '').replace(/\/+$/, '');
370
+ return normalized || '.';
371
+ }
372
+ resolveClientV3ToolPath(rawPath) {
373
+ const root = this.currentProjectPath || process.cwd();
374
+ const normalized = this.normalizeClientV3ToolPath(rawPath);
375
+ const absoluteTarget = path.resolve(root, normalized === '.' ? '' : normalized);
376
+ if (fs.existsSync(absoluteTarget)) {
377
+ return normalized;
378
+ }
379
+ const parts = normalized.split('/').filter(Boolean);
380
+ if (parts.length === 0) {
381
+ return '.';
382
+ }
383
+ try {
384
+ const entries = fs.readdirSync(root, { withFileTypes: true });
385
+ const firstKey = parts[0].toLowerCase().replace(/[\s_./\\-]+/g, '');
386
+ for (const entry of entries) {
387
+ const entryKey = entry.name.toLowerCase().replace(/[\s_./\\-]+/g, '');
388
+ if (entryKey === firstKey || entryKey.includes(firstKey) || firstKey.includes(entryKey)) {
389
+ const rest = parts.slice(1).join('/');
390
+ const candidate = rest ? `${entry.name}/${rest}` : entry.name;
391
+ if (fs.existsSync(path.resolve(root, candidate))) {
392
+ return candidate.replace(/\\/g, '/');
393
+ }
394
+ if (!rest) {
395
+ return entry.name.replace(/\\/g, '/');
396
+ }
397
+ }
398
+ }
399
+ }
400
+ catch {
401
+ // Ignore unreadable workspace roots during path repair.
402
+ }
403
+ return normalized;
404
+ }
405
+ normalizeClientV3ToolArgs(args) {
406
+ const normalizedArgs = { ...args };
407
+ for (const key of ['path', 'file_path', 'file', 'target']) {
408
+ if (typeof normalizedArgs[key] === 'string' && normalizedArgs[key].trim()) {
409
+ normalizedArgs[key] = this.resolveClientV3ToolPath(normalizedArgs[key]);
410
+ }
411
+ }
412
+ return normalizedArgs;
413
+ }
414
+ async executeClientV3Tool(event) {
415
+ if (!this.tools) {
416
+ return { success: false, output: '', error: 'Local agent tools are not initialized.' };
417
+ }
418
+ const name = String(event.name || '').trim();
419
+ const args = (event.arguments && typeof event.arguments === 'object')
420
+ ? event.arguments
421
+ : {};
422
+ let toolName = name;
423
+ let toolArgs = {};
424
+ for (const [key, value] of Object.entries(args)) {
425
+ toolArgs[key] = value == null ? '' : String(value);
426
+ }
427
+ toolArgs = this.normalizeClientV3ToolArgs(toolArgs);
428
+ if (name === 'list_directory') {
429
+ toolName = 'list_dir';
430
+ toolArgs = {
431
+ path: toolArgs.path || '.',
432
+ ...(toolArgs.recursive === 'true' || toolArgs.recursive === '1' ? { recursive: 'true' } : {}),
433
+ };
434
+ }
435
+ else if (name === 'search_files') {
436
+ toolName = 'grep';
437
+ toolArgs = {
438
+ path: toolArgs.path || '.',
439
+ pattern: toolArgs.pattern || toolArgs.query || '',
440
+ ...(toolArgs.file_pattern ? { includePattern: toolArgs.file_pattern } : {}),
441
+ };
442
+ }
443
+ else if (name === 'read_file') {
444
+ toolName = 'read_file';
445
+ }
446
+ else if (name === 'write_file' || name === 'edit_file') {
447
+ toolName = name;
448
+ }
449
+ if (!toolName) {
450
+ return { success: false, output: '', error: 'Missing V3 client tool name.' };
451
+ }
452
+ const result = await this.tools.execute({ tool: toolName, args: toolArgs });
453
+ return {
454
+ success: result.success === true,
455
+ output: String(result.output || result.message || ''),
456
+ error: result.error ? String(result.error) : '',
457
+ };
458
+ }
459
+ getActivePersonaMode() {
460
+ if (this.personaOverride)
461
+ return this.personaOverride;
462
+ return normalizePersonaMode(this.config.get('persona')) || 'default';
463
+ }
464
+ buildActivePersonaOverlay() {
465
+ return buildPersonaOverlay(this.getActivePersonaMode(), this.getLastUserPrompt());
466
+ }
323
467
  isDiagnosticPrompt(prompt) {
324
468
  return /(startup|start up|won'?t start|doesn'?t start|crash|crashes|error|errors|failing|fails|issue|issues|bug|bugs|diagnos|debug|runtime|log|logs|exception|traceback|stack trace|yaml|blocking|blocker)/i.test(prompt);
325
469
  }
@@ -328,10 +472,270 @@ export class ChatCommand {
328
472
  * question — these should use analysis_only workflow, not full_autonomy.
329
473
  */
330
474
  isAnalysisLookupPrompt(prompt) {
475
+ if (this.isImplementationPrompt(prompt)) {
476
+ return false;
477
+ }
331
478
  return /^(what|which|where|how many|who|find|list|show|check|inspect|analyze|analyse|audit|explain|describe|summarize|summarise|review|overview|count|read|look at|tell me|locate|search for|does .* exist)/i.test(prompt.trim());
332
479
  }
480
+ isImplementationPrompt(prompt) {
481
+ const trimmed = String(prompt || '').trim();
482
+ if (!trimmed)
483
+ return false;
484
+ if (/\b(implement|add|fix|repair|complete|finish|build|create|write|patch|update|modify|edit|make\s+(?:it\s+)?work(?:ing)?|get\s+(?:a\s+)?working)\b/i.test(trimmed)) {
485
+ return true;
486
+ }
487
+ if (/\bwhat(?:'s|\s+is|\s+are)\s+missing\b/i.test(trimmed)) {
488
+ return true;
489
+ }
490
+ if (/\b(game|html5|pacman|rogue|app|site)\b/i.test(trimmed) && /\b(missing|broken|fix|implement|working|playable)\b/i.test(trimmed)) {
491
+ return true;
492
+ }
493
+ if (/continue the previous agent run/i.test(trimmed) && /\b(implement|fix|missing|blocker|remaining)\b/i.test(trimmed)) {
494
+ return true;
495
+ }
496
+ return false;
497
+ }
498
+ getWindowsPromptPathRoots() {
499
+ const roots = new Set();
500
+ const add = (value) => {
501
+ const raw = String(value || '').trim();
502
+ if (!raw)
503
+ return;
504
+ try {
505
+ const resolved = path.resolve(raw);
506
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
507
+ roots.add(resolved);
508
+ }
509
+ }
510
+ catch {
511
+ // Ignore unreadable discovery roots.
512
+ }
513
+ };
514
+ const home = os.homedir();
515
+ const cwdRoot = path.parse(process.cwd()).root || path.parse(home).root || '';
516
+ add(process.cwd());
517
+ add(home);
518
+ add(path.join(home, 'Desktop'));
519
+ add(path.join(home, 'Documents'));
520
+ add(process.env.USERPROFILE);
521
+ add(process.env.OneDrive);
522
+ add(process.env.OneDriveCommercial);
523
+ add(process.env.OneDriveConsumer);
524
+ if (process.env.OneDrive) {
525
+ add(path.join(process.env.OneDrive, 'Desktop'));
526
+ add(path.join(process.env.OneDrive, 'Documents'));
527
+ }
528
+ if (cwdRoot) {
529
+ add(cwdRoot);
530
+ add(path.join(cwdRoot, 'vigthoria'));
531
+ add(path.join(cwdRoot, 'Vigthoria'));
532
+ }
533
+ return Array.from(roots);
534
+ }
535
+ findPromptDirectoryByName(rawPath) {
536
+ if (os.platform() !== 'win32')
537
+ return null;
538
+ const normalized = String(rawPath || '').trim().replace(/^\/+/, '').replace(/[\\/]+/g, path.sep);
539
+ if (!normalized)
540
+ return null;
541
+ const wantedParts = normalized.split(/[\\/]+/).filter(Boolean);
542
+ const wantedName = wantedParts[wantedParts.length - 1];
543
+ if (!wantedName)
544
+ return null;
545
+ const maxDepth = 3;
546
+ const maxEntries = 2500;
547
+ const roots = this.getWindowsPromptPathRoots();
548
+ for (const root of roots) {
549
+ let visited = 0;
550
+ const direct = path.join(root, normalized);
551
+ try {
552
+ if (fs.existsSync(direct) && fs.statSync(direct).isDirectory()) {
553
+ return direct;
554
+ }
555
+ }
556
+ catch {
557
+ // Continue with bounded search.
558
+ }
559
+ const stack = [{ dir: root, depth: 0 }];
560
+ while (stack.length > 0 && visited < maxEntries) {
561
+ const current = stack.pop();
562
+ if (!current || current.depth > maxDepth)
563
+ continue;
564
+ let entries;
565
+ try {
566
+ entries = fs.readdirSync(current.dir, { withFileTypes: true });
567
+ }
568
+ catch {
569
+ continue;
570
+ }
571
+ visited += entries.length;
572
+ for (const entry of entries) {
573
+ if (!entry.isDirectory())
574
+ continue;
575
+ if (entry.name === 'node_modules' || entry.name === '.git' || entry.name.startsWith('$'))
576
+ continue;
577
+ const fullPath = path.join(current.dir, entry.name);
578
+ if (entry.name.toLowerCase() === wantedName.toLowerCase()) {
579
+ if (wantedParts.length === 1 || fullPath.toLowerCase().endsWith(normalized.toLowerCase())) {
580
+ return fullPath;
581
+ }
582
+ }
583
+ if (current.depth < maxDepth) {
584
+ stack.push({ dir: fullPath, depth: current.depth + 1 });
585
+ }
586
+ }
587
+ }
588
+ }
589
+ return null;
590
+ }
591
+ extractExplicitLocalPath(prompt) {
592
+ const resolveExistingPath = (rawPath) => {
593
+ const trimmed = String(rawPath || '').trim().replace(/^['"]|['"]$/g, '');
594
+ if (!trimmed)
595
+ return null;
596
+ const candidates = [];
597
+ candidates.push(path.resolve(trimmed));
598
+ if (os.platform() === 'win32' && /^\/[A-Za-z0-9._ -]/.test(trimmed)) {
599
+ const withoutLeadingSlash = trimmed.replace(/^\/+/, '');
600
+ const cwdRoot = path.parse(process.cwd()).root || '';
601
+ // Allow prompts like "/Vigthoria Games" to resolve as "C:/Vigthoria/Games".
602
+ const asSegments = withoutLeadingSlash.replace(/\s+/g, path.sep);
603
+ for (const root of this.getWindowsPromptPathRoots()) {
604
+ candidates.push(path.resolve(root, withoutLeadingSlash));
605
+ candidates.push(path.resolve(root, asSegments));
606
+ const segmentParts = asSegments.split(/[\\/]+/).filter(Boolean);
607
+ if (segmentParts.length > 1 && path.basename(root).toLowerCase() === segmentParts[0].toLowerCase()) {
608
+ candidates.push(path.resolve(root, ...segmentParts.slice(1)));
609
+ }
610
+ }
611
+ if (cwdRoot && !candidates.includes(path.resolve(cwdRoot, withoutLeadingSlash))) {
612
+ candidates.push(path.resolve(cwdRoot, withoutLeadingSlash));
613
+ candidates.push(path.resolve(cwdRoot, asSegments));
614
+ }
615
+ }
616
+ for (const candidate of candidates) {
617
+ try {
618
+ if (fs.existsSync(candidate)) {
619
+ return candidate;
620
+ }
621
+ }
622
+ catch {
623
+ // Continue trying other normalized candidates.
624
+ }
625
+ }
626
+ const discovered = this.findPromptDirectoryByName(trimmed);
627
+ if (discovered)
628
+ return discovered;
629
+ return null;
630
+ };
631
+ // Quoted paths first (supports spaces safely).
632
+ const quotedPatterns = [
633
+ /"([A-Za-z]:[\\/][^"\r\n]+)"/,
634
+ /'([A-Za-z]:[\\/][^'\r\n]+)'/,
635
+ /"(\/[^"]+)"/,
636
+ /'(\/[^']+)'/,
637
+ ];
638
+ for (const pattern of quotedPatterns) {
639
+ const match = prompt.match(pattern);
640
+ if (match?.[1]) {
641
+ const resolved = resolveExistingPath(match[1]);
642
+ if (resolved)
643
+ return resolved;
644
+ }
645
+ }
646
+ // Unquoted Windows path with optional spaces, stopping before instruction connectors.
647
+ const windowsMatch = prompt.match(/(?:^|\s)([A-Za-z]:[\\/][^<>"|?*\r\n]+?)(?=(?:\s+(?:and|then|to|for|with|where|that|which|who|make|create|build|analyse|analyze)\b|\s{2,}|[.,;!?)]|$))/i);
648
+ if (windowsMatch?.[1]) {
649
+ const resolved = resolveExistingPath(windowsMatch[1]);
650
+ if (resolved)
651
+ return resolved;
652
+ }
653
+ // Unix-style absolute path with optional spaces (but not URLs).
654
+ if (!/(https?|ftp):\/\//i.test(prompt)) {
655
+ const unixMatch = prompt.match(/(?:^|\s)(\/[a-zA-Z0-9._\-/ ]+?)(?=(?:\s+(?:and|then|to|for|with|where|that|which|who|make|create|build|analyse|analyze)\b|\s{2,}|[.,;!?)]|$))/i);
656
+ if (unixMatch?.[1]) {
657
+ const candidatePath = unixMatch[1]
658
+ .replace(/\s+(and|or|at|in|the|to|for|with|from|by|on)$/i, '')
659
+ .replace(/[.,;!?:)\]]*$/, '')
660
+ .trim();
661
+ if (candidatePath.length > 1) {
662
+ const resolved = resolveExistingPath(candidatePath);
663
+ if (resolved)
664
+ return resolved;
665
+ }
666
+ }
667
+ }
668
+ return null;
669
+ }
670
+ isUnscopedPromptPathOverrideAllowed() {
671
+ return /^(1|true|yes)$/i.test(String(process.env.VIGTHORIA_ALLOW_UNSCOPED_PROMPT_PATHS || ''));
672
+ }
673
+ isPathWithinRoot(candidatePath, rootPath) {
674
+ const candidate = path.resolve(candidatePath);
675
+ const root = path.resolve(rootPath);
676
+ const rel = path.relative(root, candidate);
677
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
678
+ }
679
+ getPromptPathAllowedRoots(baseWorkspace) {
680
+ const roots = new Set();
681
+ const addRoot = (rawValue) => {
682
+ const value = String(rawValue || '').trim();
683
+ if (!value)
684
+ return;
685
+ const resolved = path.resolve(value);
686
+ try {
687
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
688
+ roots.add(resolved);
689
+ }
690
+ }
691
+ catch {
692
+ // ignore invalid or unreadable roots
693
+ }
694
+ };
695
+ addRoot(baseWorkspace);
696
+ addRoot(this.currentProjectPath);
697
+ addRoot(process.cwd());
698
+ addRoot(this.config.get('project')?.rootPath || null);
699
+ const envRootsRaw = String(process.env.VIGTHORIA_ALLOWED_WORKSPACE_ROOTS || '').trim();
700
+ if (envRootsRaw) {
701
+ for (const entry of envRootsRaw.split(path.delimiter)) {
702
+ addRoot(entry);
703
+ }
704
+ }
705
+ return Array.from(roots);
706
+ }
707
+ resolvePromptWorkspacePath(prompt, baseWorkspace) {
708
+ const explicitPath = this.extractExplicitLocalPath(prompt);
709
+ if (!explicitPath) {
710
+ return null;
711
+ }
712
+ const candidate = path.resolve(explicitPath);
713
+ if (this.isUnscopedPromptPathOverrideAllowed()) {
714
+ return candidate;
715
+ }
716
+ const runtime = this.getRuntimeEnvironmentContext();
717
+ // On local-machine CLI sessions, allow explicit absolute paths from user prompts.
718
+ // The path still must exist on disk (validated by extractExplicitLocalPath).
719
+ if (runtime.machineScope === 'local-machine') {
720
+ return candidate;
721
+ }
722
+ const allowedRoots = this.getPromptPathAllowedRoots(baseWorkspace);
723
+ const isAllowed = allowedRoots.some((root) => this.isPathWithinRoot(candidate, root));
724
+ if (isAllowed) {
725
+ return candidate;
726
+ }
727
+ if (!this.jsonOutput) {
728
+ console.log(chalk.yellow(`Ignoring path outside allowed workspace roots: ${candidate}`));
729
+ if (allowedRoots.length > 0) {
730
+ const displayRoots = allowedRoots.map((root) => root.replace(/\\/g, '/')).join(', ');
731
+ console.log(chalk.gray(`Allowed roots: ${displayRoots}`));
732
+ }
733
+ console.log(chalk.gray('To allow unrestricted prompt path overrides, set VIGTHORIA_ALLOW_UNSCOPED_PROMPT_PATHS=1.'));
734
+ }
735
+ return null;
736
+ }
333
737
  isBrowserTaskPrompt(prompt) {
334
- return /(browser|chrome|devtools|console|dom|network tab|network request|frontend runtime|client-side|client side|rendering|page load|websocket|ui bug|inspect element)/i.test(prompt);
738
+ return /(\bbrowser\b|\bchrome\b|\bdevtools\b|\bconsole\b|\bdom\b|network tab|network request|frontend runtime|client-side|client side|rendering|page load|\bwebsocket\b|ui bug|inspect element)/i.test(prompt);
335
739
  }
336
740
  /**
337
741
  * Returns true when a prompt can be answered directly without the full
@@ -377,13 +781,36 @@ export class ChatCommand {
377
781
  inferAgentTaskType(prompt) {
378
782
  if (this.isDiagnosticPrompt(prompt))
379
783
  return 'debugging';
784
+ if (this.isImplementationPrompt(prompt)) {
785
+ return /\b(game|html5|pacman|rogue|playable)\b/i.test(prompt) ? 'game-build' : 'implementation';
786
+ }
380
787
  if (/^(what|which|how many|list|show|check|inspect|analyze|analyse|audit|explain|describe|summarize|summarise|review|overview|find|count|read|look at|tell me)/i.test(prompt.trim()))
381
788
  return 'analysis';
382
789
  return 'implementation';
383
790
  }
791
+ bindPromptWorkspace(prompt) {
792
+ const resolved = this.resolvePromptWorkspacePath(prompt, this.currentProjectPath);
793
+ if (!resolved) {
794
+ return;
795
+ }
796
+ const normalizedResolved = path.resolve(resolved);
797
+ const normalizedCurrent = path.resolve(this.currentProjectPath);
798
+ if (normalizedResolved === normalizedCurrent) {
799
+ return;
800
+ }
801
+ if (!this.jsonOutput) {
802
+ console.log(chalk.cyan(`📁 Workspace from prompt: ${normalizedResolved}`));
803
+ }
804
+ this.currentProjectPath = normalizedResolved;
805
+ this.tools?.setWorkspaceRoot(normalizedResolved);
806
+ this.projectMemory = new ProjectMemoryService(normalizedResolved);
807
+ }
384
808
  buildTaskShapingInstructions(prompt) {
385
809
  const instructions = [];
386
810
  const runtime = this.getRuntimeEnvironmentContext();
811
+ if (runtime.machineScope === 'local-machine') {
812
+ instructions.push(`Execution environment: local user machine (${runtime.platform}).`, `Project workspace root on this machine: ${runtime.workspacePath}.`, 'All list_dir, read_file, grep, glob, and bash tools operate on this local filesystem — not a remote server copy.', 'Use paths relative to that root only. Never prefix paths with workspace/ or repeat the workspace folder name.', 'Start with list_dir on "." to discover the real folder structure before reading files.', 'If read_file fails, use list_dir or glob to locate the correct relative path, then retry read_file.');
813
+ }
387
814
  // Platform-aware routing hints
388
815
  if (runtime.platform === 'windows') {
389
816
  instructions.push('Platform: Windows. Use list_dir, glob, read_file, and the grep tool for searching.', 'The grep tool handles Windows automatically — do not use bash to call grep, findstr, or Select-String manually.', 'Do not use bash for Unix commands (cat, head, tail, awk, sed, wc).', 'Use read_file to inspect file contents instead of shell commands.', 'All file paths use forward slashes internally.');
@@ -495,6 +922,9 @@ export class ChatCommand {
495
922
  v3ToolCallCount = 0;
496
923
  v3LastActivity = Date.now();
497
924
  v3StreamingStarted = false;
925
+ v3StreamedTextBuffer = '';
926
+ v3LiveToolEvidence = [];
927
+ v3PendingToolCalls = [];
498
928
  /**
499
929
  * Strip server-internal path prefixes from tool output strings.
500
930
  * Prevents exposing paths like /var/www/V3-Code-Agent/... to end users.
@@ -502,7 +932,16 @@ export class ChatCommand {
502
932
  sanitizeServerPath(text) {
503
933
  if (!text)
504
934
  return text;
505
- return sanitizeUserFacingPathText(text);
935
+ return sanitizeUserFacingPathText(this.stripHiddenThoughtBlocks(text));
936
+ }
937
+ stripHiddenThoughtBlocks(text) {
938
+ if (!text)
939
+ return text;
940
+ return text
941
+ .replace(/<\|mask_start\|>[\s\S]*?<\|mask_end\|>/g, '')
942
+ .replace(/<think>[\s\S]*?<\/think>/gi, '')
943
+ .replace(/<\|(?:mask_start|mask_end)\|>/g, '')
944
+ .trim();
506
945
  }
507
946
  describeV3AgentTool(toolName) {
508
947
  const normalized = String(toolName || '').toLowerCase();
@@ -560,6 +999,7 @@ export class ChatCommand {
560
999
  if (!safeText) {
561
1000
  return;
562
1001
  }
1002
+ this.v3StreamedTextBuffer += safeText;
563
1003
  if (!this.v3StreamingStarted) {
564
1004
  this.v3StreamingStarted = true;
565
1005
  spinner.stop();
@@ -572,6 +1012,117 @@ export class ChatCommand {
572
1012
  }
573
1013
  process.stdout.write(safeText);
574
1014
  }
1015
+ isGenericV3AgentContent(text) {
1016
+ const value = String(text || '').trim();
1017
+ return !value || /^(v3 agent workflow completed\.?|task completed|agent run finished|workflow completed\.?)$/i.test(value);
1018
+ }
1019
+ hasAlreadyStreamedV3Content(text) {
1020
+ const value = String(text || '').trim();
1021
+ if (!value)
1022
+ return true;
1023
+ return this.v3StreamedTextBuffer.includes(value) || value.includes(this.v3StreamedTextBuffer.trim());
1024
+ }
1025
+ isThinV3Summary(text) {
1026
+ const value = String(text || '').trim();
1027
+ if (!value)
1028
+ return true;
1029
+ if (/^#\s*Workspace overview/m.test(value) || /^## Workspace analysis \(from local file inspection\)/m.test(value)) {
1030
+ return false;
1031
+ }
1032
+ if (this.isGenericV3AgentContent(value))
1033
+ return true;
1034
+ if (/^The V3 agent finished without emitting a dedicated final answer/i.test(value)) {
1035
+ return !/(## Files read|## Directories inspected|### )/i.test(value);
1036
+ }
1037
+ return value.length < 120;
1038
+ }
1039
+ shouldPrintV3FinalContent(text) {
1040
+ if (!text || this.isGenericV3AgentContent(text)) {
1041
+ return false;
1042
+ }
1043
+ const value = text.trim();
1044
+ if (/^## Workspace analysis \(from local file inspection\)/m.test(value)) {
1045
+ return true;
1046
+ }
1047
+ if (/Reconstructed task summary|Workspace analysis \(from local file inspection\)/i.test(value)) {
1048
+ return true;
1049
+ }
1050
+ if (!this.v3StreamingStarted) {
1051
+ return true;
1052
+ }
1053
+ return !this.hasAlreadyStreamedV3Content(value);
1054
+ }
1055
+ rememberV3ToolEvidence(event, args = {}) {
1056
+ const output = typeof event?.output === 'string' ? event.output.trim() : '';
1057
+ const errorText = typeof event?.error === 'string' ? event.error.trim() : '';
1058
+ const combined = output || errorText;
1059
+ if (!combined) {
1060
+ return;
1061
+ }
1062
+ const target = String(args.path || args.file_path || args.file || args.target || event?.target || '').trim();
1063
+ this.v3LiveToolEvidence.push({
1064
+ name: String(event?.name || event?.tool || 'unknown_tool'),
1065
+ target: target || undefined,
1066
+ arguments: args,
1067
+ output: combined,
1068
+ success: event?.success !== false,
1069
+ });
1070
+ }
1071
+ buildUserFacingV3RunReport(prompt, workspacePath, options = {}) {
1072
+ const evidenceBody = this.api.formatV3AgentResponse({
1073
+ events: [],
1074
+ liveToolEvidence: this.v3LiveToolEvidence,
1075
+ });
1076
+ const successes = this.v3LiveToolEvidence.filter((entry) => entry.success !== false);
1077
+ const failures = this.v3LiveToolEvidence.filter((entry) => entry.success === false);
1078
+ if (!evidenceBody && successes.length === 0 && failures.length === 0) {
1079
+ return [
1080
+ '# Agent run summary',
1081
+ '',
1082
+ `**Workspace:** ${workspacePath}`,
1083
+ `**Your request:** ${prompt.trim()}`,
1084
+ '',
1085
+ 'The agent finished without successfully reading local files, so no grounded overview could be built.',
1086
+ options.serverNote ? `\n**Server note:** ${options.serverNote}` : '',
1087
+ '',
1088
+ 'Try `/continue` with: "Read Vigthoria-dominion/package.json, game.js, src/Game.js, and src/factions/, then write a full overview."',
1089
+ ].filter(Boolean).join('\n');
1090
+ }
1091
+ const lines = [
1092
+ '# Workspace overview',
1093
+ '',
1094
+ `**Workspace:** ${workspacePath}`,
1095
+ `**Your request:** ${prompt.trim()}`,
1096
+ options.partial
1097
+ ? '**Status:** Partial — the agent used its iteration budget before writing a final narrative. The report below is rebuilt from files it actually read on your machine.'
1098
+ : '**Status:** Complete — summary rebuilt from local file inspection.',
1099
+ '',
1100
+ evidenceBody || '_No readable file excerpts were captured._',
1101
+ ];
1102
+ if (failures.length > 0) {
1103
+ const uniqueFails = new Map();
1104
+ for (const entry of failures) {
1105
+ const failPath = entry.target || entry.arguments?.path || entry.name;
1106
+ if (!uniqueFails.has(failPath)) {
1107
+ uniqueFails.set(failPath, entry.output.split('\n').find(Boolean)?.slice(0, 140) || entry.output.slice(0, 140));
1108
+ }
1109
+ }
1110
+ if (uniqueFails.size > 0 && !/## Paths not found/i.test(evidenceBody || '')) {
1111
+ lines.push('', '## Paths not found (exploration misses)', ...[...uniqueFails.entries()].slice(0, 12).map(([failPath, detail]) => `- \`${failPath}\`${detail ? ` — ${detail}` : ''}`), '', '_These are usually incorrect guesses (for example `Vigthoria-dominion/entities` instead of `Vigthoria-dominion/src/entities`). They do not mean your project is broken._');
1112
+ }
1113
+ }
1114
+ lines.push('', '---', 'Use `/continue` if you want a longer narrative summary or a deep dive into a specific subfolder.');
1115
+ return lines.join('\n');
1116
+ }
1117
+ printV3UserReport(report) {
1118
+ const value = String(report || '').trim();
1119
+ if (!value || this.isGenericV3AgentContent(value)) {
1120
+ return;
1121
+ }
1122
+ console.log();
1123
+ console.log(value);
1124
+ console.log();
1125
+ }
575
1126
  updateV3AgentSpinner(spinner, event) {
576
1127
  if (this.isRawV3StreamPayload(event)) {
577
1128
  this.consumeV3StreamPayload(spinner, event).catch((error) => {
@@ -589,7 +1140,7 @@ export class ChatCommand {
589
1140
  if (event.type === 'tool_call') {
590
1141
  this.v3ToolCallCount += 1;
591
1142
  const toolDesc = this.describeV3AgentTool(event.tool || event.name || event.tool_name);
592
- const toolTarget = event.arguments?.path || event.arguments?.file_path || event.arguments?.pattern || '';
1143
+ const toolTarget = event.arguments?.path || event.arguments?.file_path || event.arguments?.pattern || event.arguments?.query || '';
593
1144
  const sanitizedTarget = this.sanitizeServerPath(String(toolTarget));
594
1145
  const shortTarget = sanitizedTarget ? ` → ${sanitizedTarget.replace(/\\/g, '/').split('/').slice(-2).join('/')}` : '';
595
1146
  const stepLabel = chalk.cyan(` [${this.v3IterationCount}/${this.v3ToolCallCount}]`) + ` ${toolDesc}${shortTarget}`;
@@ -599,6 +1150,21 @@ export class ChatCommand {
599
1150
  // Show extra detail for key tools
600
1151
  const args = event.arguments || {};
601
1152
  const toolName = event.name || event.tool || '';
1153
+ this.v3PendingToolCalls.push({
1154
+ name: String(toolName || 'unknown_tool'),
1155
+ args: args && typeof args === 'object' ? args : {},
1156
+ });
1157
+ if (toolName === 'search_files') {
1158
+ const pattern = String(args.pattern || args.query || '').trim();
1159
+ const searchPath = String(args.path || '.').trim();
1160
+ process.stderr.write(chalk.gray(` search: ${pattern || '(empty)'} in ${this.sanitizeServerPath(searchPath)}\n`));
1161
+ }
1162
+ else if (toolName === 'list_directory') {
1163
+ process.stderr.write(chalk.gray(` list: ${this.sanitizeServerPath(String(args.path || '.'))}${args.recursive ? ' (recursive)' : ''}\n`));
1164
+ }
1165
+ else if (toolName === 'read_file') {
1166
+ process.stderr.write(chalk.gray(` read: ${this.sanitizeServerPath(String(args.path || args.file_path || ''))}\n`));
1167
+ }
602
1168
  if ((toolName === 'write_file' || toolName === 'edit_file') && typeof args.content === 'string') {
603
1169
  const len = args.content.length;
604
1170
  process.stderr.write(chalk.gray(` ${len > 1000 ? Math.round(len / 1024) + ' KB' : len + ' bytes'} content\n`));
@@ -621,25 +1187,51 @@ export class ChatCommand {
621
1187
  // Show output for failures, or brief summary for successes — sanitize server paths
622
1188
  const rawOutput = typeof event.output === 'string' ? event.output.trim() : '';
623
1189
  const output = this.sanitizeServerPath(rawOutput);
1190
+ const pendingIndex = this.v3PendingToolCalls.findIndex((call) => call.name === toolName);
1191
+ const pendingCall = pendingIndex >= 0
1192
+ ? this.v3PendingToolCalls.splice(pendingIndex, 1)[0]
1193
+ : this.v3PendingToolCalls.shift();
1194
+ this.rememberV3ToolEvidence(event, pendingCall?.args || {});
624
1195
  if (!success && output) {
625
1196
  const sanitizedError = this.sanitizeServerPath(typeof event.error === 'string' ? event.error : output);
626
1197
  const lines = sanitizedError.split('\n').slice(0, 4);
627
1198
  process.stderr.write(chalk.red(` ${lines.join('\n ')}\n`));
628
1199
  }
629
1200
  else if (success && output && output.length > 0) {
630
- const brief = output.split('\n')[0].slice(0, 120);
1201
+ const outputLines = output.split('\n').map((line) => line.trimEnd()).filter(Boolean);
1202
+ const firstMeaningfulLine = outputLines.find((line) => !/^## Untrusted workspace data boundary/i.test(line)) || outputLines[0] || '';
1203
+ const brief = firstMeaningfulLine.slice(0, 160);
631
1204
  process.stderr.write(chalk.gray(` ${brief}${output.length > 120 ? '…' : ''}\n`));
632
1205
  }
633
1206
  spinner.start();
634
1207
  spinner.text = 'Next step...';
635
1208
  return;
636
1209
  }
1210
+ if (event.type === 'v3_status') {
1211
+ spinner.text = this.sanitizeServerPath(String(event.content || 'Routing to V3 Agent...'));
1212
+ return;
1213
+ }
1214
+ if (event.type === 'queued') {
1215
+ if (spinner.isSpinning)
1216
+ spinner.stop();
1217
+ process.stderr.write(chalk.yellow(' [Queue] ') + this.sanitizeServerPath(String(event.message || 'Waiting for V3 capacity...')) + '\n');
1218
+ spinner.start();
1219
+ spinner.text = 'Waiting for V3...';
1220
+ return;
1221
+ }
1222
+ if (event.type === 'heartbeat') {
1223
+ spinner.text = 'V3 Agent is still thinking...';
1224
+ return;
1225
+ }
637
1226
  if (event.type === 'thinking') {
638
1227
  this.v3IterationCount += 1;
639
1228
  const iterText = this.sanitizeServerPath(event.content || '');
640
1229
  if (spinner.isSpinning)
641
1230
  spinner.stop();
642
- process.stderr.write(chalk.cyan(`\n── ${iterText || `Iteration ${this.v3IterationCount}`} ──\n`));
1231
+ process.stderr.write(chalk.cyan(`\n── Iteration ${this.v3IterationCount}${iterText ? '' : '...'} ──\n`));
1232
+ if (iterText) {
1233
+ process.stderr.write(chalk.gray(`${iterText}\n`));
1234
+ }
643
1235
  spinner.start();
644
1236
  spinner.text = 'Analyzing...';
645
1237
  return;
@@ -813,6 +1405,22 @@ export class ChatCommand {
813
1405
  process.stderr.write(chalk.cyan(' [Start] ') + 'Agent initialized\n');
814
1406
  spinner.start();
815
1407
  spinner.text = 'Working...';
1408
+ return;
1409
+ }
1410
+ const fallbackStatus = typeof event.status === 'string' ? this.sanitizeServerPath(event.status) : '';
1411
+ const fallbackStage = typeof event.stage === 'string' ? this.sanitizeServerPath(event.stage) : '';
1412
+ const fallbackMessage = typeof event.message === 'string'
1413
+ ? this.sanitizeServerPath(event.message)
1414
+ : (typeof event.content === 'string' ? this.sanitizeServerPath(event.content) : '');
1415
+ if (event.type === 'status' || event.type === 'progress' || fallbackStatus || fallbackStage) {
1416
+ if (spinner.isSpinning)
1417
+ spinner.stop();
1418
+ if (fallbackMessage) {
1419
+ process.stderr.write(chalk.cyan(' [V3] ') + `${fallbackMessage}\n`);
1420
+ }
1421
+ spinner.start();
1422
+ spinner.text = fallbackStatus || fallbackStage || 'Working...';
1423
+ return;
816
1424
  }
817
1425
  }
818
1426
  updateOperatorSpinner(spinner, event) {
@@ -878,6 +1486,7 @@ export class ChatCommand {
878
1486
  }
879
1487
  return;
880
1488
  }
1489
+ await this.config.refreshHubModelPreferences().catch(() => null);
881
1490
  this.agentMode = options.agent === true;
882
1491
  this.operatorMode = options.operator === true;
883
1492
  this.workflowTarget = typeof options.workflow === 'string' && options.workflow.trim()
@@ -886,6 +1495,7 @@ export class ChatCommand {
886
1495
  this.savePlanToVigFlow = options.savePlan === true;
887
1496
  this.jsonOutput = options.json === true;
888
1497
  this.autoApprove = options.autoApprove === true || this.jsonOutput;
1498
+ this.personaOverride = options.grant === true ? 'wiener_grant' : null;
889
1499
  this.modelExplicitlySelected = Boolean(String(options.model || '').trim());
890
1500
  this.currentModel = this.resolveInitialModel(options);
891
1501
  this.applyNoAgentGovernance(String(options.model || this.currentModel || ''));
@@ -956,7 +1566,10 @@ export class ChatCommand {
956
1566
  bridge.destroy();
957
1567
  }
958
1568
  }
959
- return;
1569
+ // Force-exit: undici + chokidar + HTTPS pool keep the Node.js event
1570
+ // loop alive indefinitely in direct prompt mode; a clean exit is safe here.
1571
+ this.api.destroy();
1572
+ process.exit(process.exitCode ?? 0);
960
1573
  }
961
1574
  await this.startInteractiveChat();
962
1575
  const bridge = getBridgeClient();
@@ -998,11 +1611,57 @@ export class ChatCommand {
998
1611
  }
999
1612
  fs.mkdirSync(this.currentProjectPath, { recursive: true });
1000
1613
  }
1614
+ shouldStartWorkspaceWatcher(workspaceRoot) {
1615
+ if (!workspaceRoot) {
1616
+ return false;
1617
+ }
1618
+ const resolved = path.resolve(workspaceRoot);
1619
+ if (!fs.existsSync(resolved)) {
1620
+ return false;
1621
+ }
1622
+ let stat;
1623
+ try {
1624
+ stat = fs.statSync(resolved);
1625
+ }
1626
+ catch {
1627
+ return false;
1628
+ }
1629
+ if (!stat.isDirectory()) {
1630
+ return false;
1631
+ }
1632
+ const homeDir = path.resolve(os.homedir());
1633
+ const normalized = resolved.replace(/\\/g, '/');
1634
+ const normalizedHome = homeDir.replace(/\\/g, '/');
1635
+ // Guardrail: do not watch broad home/root scopes on interactive shells.
1636
+ if (normalized.toLowerCase() === normalizedHome.toLowerCase()) {
1637
+ if (!this.jsonOutput) {
1638
+ console.log(chalk.gray('Info: workspace watcher disabled for home directory scope.'));
1639
+ }
1640
+ return false;
1641
+ }
1642
+ if (/^[a-zA-Z]:\/$/.test(normalized) || normalized === '/') {
1643
+ if (!this.jsonOutput) {
1644
+ console.log(chalk.gray('Info: workspace watcher disabled for filesystem root scope.'));
1645
+ }
1646
+ return false;
1647
+ }
1648
+ return true;
1649
+ }
1001
1650
  resolveProjectPath(options) {
1002
1651
  const requestedProject = String(options.project || '').trim();
1003
1652
  if (requestedProject) {
1004
1653
  return path.resolve(requestedProject);
1005
1654
  }
1655
+ // Check if prompt contains an explicit local path provided by the user
1656
+ if (options.prompt) {
1657
+ const explicitPath = this.resolvePromptWorkspacePath(options.prompt, process.cwd());
1658
+ if (explicitPath) {
1659
+ if (!this.jsonOutput) {
1660
+ console.log(chalk.gray(`📁 Using project path from prompt: ${explicitPath}`));
1661
+ }
1662
+ return explicitPath;
1663
+ }
1664
+ }
1006
1665
  if (this.shouldUseManagedWorkspace(options)) {
1007
1666
  const rootPath = this.getManagedWorkspaceRoot();
1008
1667
  fs.mkdirSync(rootPath, { recursive: true });
@@ -1465,6 +2124,26 @@ export class ChatCommand {
1465
2124
  }
1466
2125
  }
1467
2126
  async runSimplePrompt(prompt) {
2127
+ if (!this.directPromptMode && !this.operatorMode) {
2128
+ const promptToRun = this.isConfirmationFollowUp(prompt) && this.lastActionableUserInput && this.isRepoGroundedPrompt(this.lastActionableUserInput)
2129
+ ? this.lastActionableUserInput
2130
+ : prompt;
2131
+ if (this.isRepoGroundedPrompt(promptToRun) || /\b(build|implement|complete|fix|create|make|edit|write|change|finish)\b/i.test(promptToRun)) {
2132
+ if (!this.tools) {
2133
+ throw new Error('Agent tools are not initialized.');
2134
+ }
2135
+ this.agentMode = true;
2136
+ this.syncInteractiveModeModel('agent');
2137
+ if (this.currentSession) {
2138
+ this.currentSession.agentMode = true;
2139
+ this.currentSession.model = this.currentModel;
2140
+ }
2141
+ console.log(chalk.yellow('This request needs file access, so I am switching to Agent mode and working in this workspace now.'));
2142
+ console.log(chalk.gray('You do not need to confirm with "yes"; I will continue until the agent run finishes or reports a blocker.'));
2143
+ await this.runAgentTurn(promptToRun);
2144
+ return;
2145
+ }
2146
+ }
1468
2147
  this.lastActionableUserInput = prompt;
1469
2148
  // For direct --prompt mode with simple prompts, use a minimal system
1470
2149
  // message to avoid polluting the response with tool/platform context.
@@ -1525,7 +2204,7 @@ export class ChatCommand {
1525
2204
  const response = await this.callApi('Send chat message', () => this.api.chat(this.getMessagesForModel(), this.currentModel));
1526
2205
  if (spinner)
1527
2206
  spinner.stop();
1528
- const finalText = (response.message || '').trim();
2207
+ const finalText = this.sanitizeDirectModeOutput(this.stripHiddenThoughtBlocks(response.message || '')).trim();
1529
2208
  const effectiveModel = String(response.model || this.currentModel);
1530
2209
  const metadata = this.modelGovernanceFallback
1531
2210
  ? { modelFallback: this.modelGovernanceFallback }
@@ -1570,21 +2249,32 @@ export class ChatCommand {
1570
2249
  }
1571
2250
  }
1572
2251
  }
1573
- async runAgentTurn(prompt) {
2252
+ async runAgentTurn(prompt, options = {}) {
1574
2253
  if (!this.tools) {
1575
2254
  throw new Error('Agent tools are not initialized.');
1576
2255
  }
2256
+ this.bindPromptWorkspace(prompt);
1577
2257
  const requiresV3Workflow = this.shouldRequireV3AgentWorkflow(prompt);
1578
2258
  const handledByDirectFileFlow = await this.tryDirectSingleFileFlow(prompt);
1579
2259
  if (handledByDirectFileFlow) {
1580
2260
  this.saveSession();
1581
2261
  return;
1582
2262
  }
1583
- if (this.shouldPreferLocalAgentLoop(prompt)) {
1584
- await this.runLocalAgentLoop(prompt);
1585
- return;
2263
+ // Prime the message context with the target file when the direct-file flow was
2264
+ // bypassed (e.g. HTML files routed to V3) so the local agent loop has file
2265
+ // awareness and the ⚙ Executing: read_file banner is always emitted.
2266
+ await this.primeBypassedTargetFileContext(prompt);
2267
+ if (!options.skipLocalLoop && !options.forceV3 && this.shouldPreferLocalAgentLoop(prompt)) {
2268
+ const completed = await this.runLocalAgentLoop(prompt);
2269
+ if (completed) {
2270
+ this.saveSession();
2271
+ return;
2272
+ }
2273
+ if (!this.jsonOutput) {
2274
+ console.log(chalk.yellow('Local chat backend unavailable (self-hosted inference may be offline). Trying V3 agent workflow...'));
2275
+ }
1586
2276
  }
1587
- const handledByV3Workflow = await this.tryV3AgentWorkflow(prompt);
2277
+ const handledByV3Workflow = await this.tryV3AgentWorkflow(prompt, options);
1588
2278
  if (handledByV3Workflow) {
1589
2279
  this.saveSession();
1590
2280
  return;
@@ -1598,10 +2288,56 @@ export class ChatCommand {
1598
2288
  }
1599
2289
  await this.runLocalAgentLoop(prompt);
1600
2290
  }
2291
+ localAgentIterationCount = 0;
2292
+ buildLocalAgentChatOptions(preflight, onRouteAttempt) {
2293
+ const preferredRoute = preflight?.endpoint
2294
+ ? this.api.mapPreflightEndpointToRoute(preflight.endpoint)
2295
+ : undefined;
2296
+ return {
2297
+ fastFail: true,
2298
+ singleRoute: true,
2299
+ stream: true,
2300
+ connectTimeoutMs: Number.parseInt(process.env.VIGTHORIA_CHAT_CONNECT_TIMEOUT_MS || '20000', 10),
2301
+ idleTimeoutMs: Number.parseInt(process.env.VIGTHORIA_CHAT_IDLE_TIMEOUT_MS || '120000', 10),
2302
+ preferredRoute: preferredRoute || 'coder',
2303
+ onRouteAttempt,
2304
+ };
2305
+ }
2306
+ printChatModelPreflight(preflight) {
2307
+ if (this.jsonOutput) {
2308
+ return;
2309
+ }
2310
+ console.log(chalk.gray('Model backend preflight:'));
2311
+ for (const route of preflight.routes) {
2312
+ const marker = route.ok ? chalk.green(' ✓') : chalk.red(' ✗');
2313
+ const detail = route.error ? chalk.gray(` — ${route.error}`) : '';
2314
+ console.log(`${marker} ${route.name}${detail}`);
2315
+ }
2316
+ if (preflight.healthy) {
2317
+ console.log(chalk.gray(`Using: ${preflight.endpoint}`));
2318
+ }
2319
+ else if (preflight.error) {
2320
+ console.log(chalk.yellow(preflight.error));
2321
+ }
2322
+ console.log();
2323
+ }
1601
2324
  async runLocalAgentLoop(prompt) {
1602
2325
  if (!this.tools) {
1603
2326
  throw new Error('Agent tools are not initialized.');
1604
2327
  }
2328
+ this.localAgentIterationCount = 0;
2329
+ const runtime = this.getRuntimeEnvironmentContext();
2330
+ if (!this.jsonOutput) {
2331
+ console.log();
2332
+ console.log(chalk.gray('━━━ ROUTING DECISION ━━━'));
2333
+ console.log(chalk.gray('Reason: local-machine-agent-loop'));
2334
+ console.log(chalk.gray(`Platform: ${runtime.platform}`));
2335
+ console.log(chalk.gray(`Machine scope: ${runtime.machineScope}`));
2336
+ console.log(chalk.gray(`Workspace: ${runtime.workspacePath}`));
2337
+ console.log(chalk.gray('Tools execute on your local filesystem.'));
2338
+ console.log(chalk.gray('━'.repeat(30)));
2339
+ console.log();
2340
+ }
1605
2341
  this.lastActionableUserInput = prompt;
1606
2342
  this.directToolContinuationCount = 0;
1607
2343
  this.agentToolEvidence = { discovery: 0, mutation: 0, searchFailed: 0 };
@@ -1610,12 +2346,50 @@ export class ChatCommand {
1610
2346
  this.ensureAgentSystemPrompt();
1611
2347
  this.messages.push({ role: 'user', content: this.buildScopedUserPrompt(prompt) });
1612
2348
  this.saveSession();
2349
+ const preflightSpinner = this.jsonOutput
2350
+ ? null
2351
+ : createSpinner({ text: 'Checking model connection (preflight)...', spinner: 'clock' }).start();
2352
+ let preflight;
2353
+ try {
2354
+ preflight = await this.api.runChatModelPreflight(this.currentModel);
2355
+ }
2356
+ finally {
2357
+ if (preflightSpinner) {
2358
+ preflightSpinner.stop();
2359
+ }
2360
+ }
2361
+ this.printChatModelPreflight(preflight);
2362
+ if (!preflight.healthy) {
2363
+ return false;
2364
+ }
1613
2365
  const maxTurns = 10;
2366
+ const preferredRoute = preflight?.endpoint
2367
+ ? (this.api.mapPreflightEndpointToRoute(preflight.endpoint) || 'coder')
2368
+ : 'coder';
1614
2369
  for (let turn = 0; turn < maxTurns; turn += 1) {
1615
- const spinner = this.jsonOutput ? null : createSpinner({ text: turn === 0 ? 'Planning...' : 'Continuing...', spinner: 'clock' }).start();
2370
+ const spinner = this.jsonOutput ? null : createSpinner({ text: turn === 0 ? 'Planning first tool step...' : 'Continuing...', spinner: 'clock' }).start();
2371
+ let streamedVisible = '';
1616
2372
  let response;
1617
2373
  try {
1618
- response = await this.callApi('Send agent chat message', () => this.api.chat(this.getMessagesForModel(), this.currentModel), 0);
2374
+ response = await this.callApi('Send agent chat message', () => this.api.chat(this.getMessagesForModel({ compact: turn === 0 }), this.currentModel, false, {
2375
+ fastFail: true,
2376
+ singleRoute: true,
2377
+ stream: true,
2378
+ connectTimeoutMs: Number.parseInt(process.env.VIGTHORIA_CHAT_CONNECT_TIMEOUT_MS || '20000', 10),
2379
+ idleTimeoutMs: Number.parseInt(process.env.VIGTHORIA_CHAT_IDLE_TIMEOUT_MS || '120000', 10),
2380
+ preferredRoute,
2381
+ onRouteAttempt: (routeLabel) => {
2382
+ if (spinner) {
2383
+ spinner.text = routeLabel;
2384
+ }
2385
+ },
2386
+ onStreamDelta: (chunk) => {
2387
+ streamedVisible += chunk;
2388
+ if (spinner) {
2389
+ spinner.text = streamedVisible.trim().slice(-72) || 'Streaming model output...';
2390
+ }
2391
+ },
2392
+ }), 0);
1619
2393
  }
1620
2394
  catch (firstErr) {
1621
2395
  // If we already gathered evidence and the model API fails on a
@@ -1624,7 +2398,14 @@ export class ChatCommand {
1624
2398
  this.logger.debug('Agent continuation API call failed, retrying once...');
1625
2399
  try {
1626
2400
  await new Promise(r => setTimeout(r, 2000));
1627
- response = await this.callApi('Retry agent chat message', () => this.api.chat(this.getMessagesForModel(), this.currentModel), 0);
2401
+ response = await this.callApi('Retry agent chat message', () => this.api.chat(this.getMessagesForModel(), this.currentModel, false, {
2402
+ fastFail: true,
2403
+ singleRoute: true,
2404
+ stream: true,
2405
+ connectTimeoutMs: Number.parseInt(process.env.VIGTHORIA_CHAT_CONNECT_TIMEOUT_MS || '20000', 10),
2406
+ idleTimeoutMs: Number.parseInt(process.env.VIGTHORIA_CHAT_IDLE_TIMEOUT_MS || '120000', 10),
2407
+ preferredRoute,
2408
+ }), 0);
1628
2409
  }
1629
2410
  catch (retryErr) {
1630
2411
  // Retry also failed — synthesize an answer from evidence
@@ -1647,13 +2428,57 @@ export class ChatCommand {
1647
2428
  console.log(fallbackContent);
1648
2429
  }
1649
2430
  this.saveSession();
1650
- return;
2431
+ return true;
1651
2432
  }
1652
2433
  throw retryErr;
1653
2434
  }
1654
2435
  }
1655
2436
  else {
1656
- throw firstErr;
2437
+ const cliErr = firstErr instanceof CLIError ? firstErr : classifyError(firstErr);
2438
+ const formatted = formatCLIError(cliErr);
2439
+ if (spinner)
2440
+ spinner.stop();
2441
+ this.rememberBrainEvent('issue', 'Agent model request failed: ' + formatted, 'agent');
2442
+ if (turn === 0 && (cliErr.category === 'model_backend' || cliErr.category === 'network' || cliErr.category === 'timeout')) {
2443
+ if (!this.jsonOutput) {
2444
+ this.logger.error(formatted);
2445
+ const transport = this.api.getLastChatTransportErrors();
2446
+ if (transport.length > 0) {
2447
+ console.log(chalk.gray(`Routes tried: ${transport.slice(0, 3).join(' | ')}`));
2448
+ }
2449
+ }
2450
+ else {
2451
+ process.exitCode = 1;
2452
+ console.log(JSON.stringify({
2453
+ success: false,
2454
+ mode: 'agent',
2455
+ model: this.currentModel,
2456
+ partial: false,
2457
+ content: '',
2458
+ error: formatted,
2459
+ errorCategory: cliErr.category,
2460
+ metadata: { executionPath: 'local-agent-loop' },
2461
+ }, null, 2));
2462
+ }
2463
+ return false;
2464
+ }
2465
+ if (this.jsonOutput) {
2466
+ process.exitCode = 1;
2467
+ console.log(JSON.stringify({
2468
+ success: false,
2469
+ mode: 'agent',
2470
+ model: this.currentModel,
2471
+ partial: false,
2472
+ content: '',
2473
+ error: formatted,
2474
+ errorCategory: cliErr.category,
2475
+ metadata: { executionPath: 'local-agent-loop' },
2476
+ }, null, 2));
2477
+ }
2478
+ else {
2479
+ this.logger.error(formatted);
2480
+ }
2481
+ return true;
1657
2482
  }
1658
2483
  }
1659
2484
  if (spinner)
@@ -1663,6 +2488,11 @@ export class ChatCommand {
1663
2488
  this.messages.push({ role: 'assistant', content: assistantMessage });
1664
2489
  const toolCalls = this.extractToolCalls(assistantMessage);
1665
2490
  const visibleText = this.stripToolPayloads(assistantMessage).trim();
2491
+ if (visibleText && !this.jsonOutput) {
2492
+ this.localAgentIterationCount += 1;
2493
+ console.log(chalk.cyan(`\n── Iteration ${this.localAgentIterationCount} ──`));
2494
+ console.log(chalk.gray(visibleText));
2495
+ }
1666
2496
  getBridgeClient()?.emitModelResponse({
1667
2497
  model: this.currentModel,
1668
2498
  chars: assistantMessage.length,
@@ -1731,7 +2561,7 @@ export class ChatCommand {
1731
2561
  console.log(finalContent);
1732
2562
  }
1733
2563
  this.saveSession();
1734
- return;
2564
+ return true;
1735
2565
  }
1736
2566
  await this.executeToolCalls(toolCalls);
1737
2567
  this.directToolContinuationCount += 1;
@@ -1765,7 +2595,7 @@ export class ChatCommand {
1765
2595
  else {
1766
2596
  this.logger.error(errorMsg);
1767
2597
  }
1768
- return;
2598
+ return true;
1769
2599
  }
1770
2600
  }
1771
2601
  if (this.jsonOutput) {
@@ -1786,6 +2616,36 @@ export class ChatCommand {
1786
2616
  console.log('Task complete.');
1787
2617
  }
1788
2618
  this.saveSession();
2619
+ return true;
2620
+ }
2621
+ async primeBypassedTargetFileContext(prompt) {
2622
+ if (!this.directPromptMode || !this.tools) {
2623
+ return;
2624
+ }
2625
+ const targetFile = this.inferTargetFileFromPrompt(prompt);
2626
+ if (!targetFile) {
2627
+ return;
2628
+ }
2629
+ // Only prime if the direct-file flow was bypassed for this file (e.g. HTML routed to V3).
2630
+ // This ensures the local agent loop always has full file awareness before planning.
2631
+ if (!this.shouldBypassDirectSingleFileFlow(targetFile, prompt)) {
2632
+ return;
2633
+ }
2634
+ const readCall = {
2635
+ tool: 'read_file',
2636
+ args: { path: targetFile },
2637
+ };
2638
+ if (!this.jsonOutput) {
2639
+ console.log(chalk.cyan(`⚙ Executing: ${readCall.tool}`));
2640
+ }
2641
+ const readResult = await this.tools.execute(readCall);
2642
+ const readSummary = this.formatToolResult(readCall, readResult);
2643
+ if (!this.jsonOutput) {
2644
+ console.log(readResult.success ? chalk.gray(readSummary) : chalk.red(readSummary));
2645
+ }
2646
+ if (readResult.success && readResult.output) {
2647
+ this.messages.push({ role: 'system', content: readSummary });
2648
+ }
1789
2649
  }
1790
2650
  async tryDirectSingleFileFlow(prompt) {
1791
2651
  if (!this.directPromptMode || !this.tools) {
@@ -1942,7 +2802,16 @@ export class ChatCommand {
1942
2802
  'Do not reinterpret this confirmation as a new website, landing page, template, or index.html task.',
1943
2803
  ].join('\n');
1944
2804
  }
1945
- async tryV3AgentWorkflow(prompt) {
2805
+ async tryV3AgentWorkflow(prompt, options = {}) {
2806
+ // Extract explicit workspace path from prompt (if provided by user)
2807
+ let promptWorkspacePath = null;
2808
+ try {
2809
+ promptWorkspacePath = this.resolvePromptWorkspacePath(prompt, this.currentProjectPath);
2810
+ }
2811
+ catch {
2812
+ // Path extraction failed, use default workspace
2813
+ }
2814
+ const workspacePath = promptWorkspacePath || this.currentProjectPath;
1946
2815
  const contextualPrompt = this.buildContextualAgentPrompt(prompt);
1947
2816
  if (contextualPrompt === prompt && !this.isConfirmationFollowUp(prompt)) {
1948
2817
  this.lastActionableUserInput = prompt;
@@ -1950,11 +2819,28 @@ export class ChatCommand {
1950
2819
  this.messages.push({ role: 'user', content: contextualPrompt });
1951
2820
  const runtimeContext = await this.getPromptRuntimeContext(contextualPrompt);
1952
2821
  const routingPolicy = this.resolveAgentExecutionPolicy(contextualPrompt);
2822
+ // STREAMING: Log routing decision transparently to user
2823
+ if (!this.jsonOutput) {
2824
+ console.log();
2825
+ console.log(chalk.gray('━━━ ROUTING DECISION ━━━'));
2826
+ console.log(chalk.gray(`Reason: ${routingPolicy.routeReason}`));
2827
+ console.log(chalk.gray(`Model: ${routingPolicy.selectedModel}`));
2828
+ console.log(chalk.gray(`Cloud Eligible: ${routingPolicy.cloudEligible}`));
2829
+ console.log(chalk.gray(`Cloud Selected: ${routingPolicy.cloudSelected}`));
2830
+ if (routingPolicy.heavyTask) {
2831
+ console.log(chalk.gray(`Task Complexity: HEAVY`));
2832
+ }
2833
+ console.log(chalk.gray('━'.repeat(30)));
2834
+ console.log();
2835
+ }
1953
2836
  // Reset streaming counters for new workflow
1954
2837
  this.v3IterationCount = 0;
1955
2838
  this.v3ToolCallCount = 0;
1956
2839
  this.v3LastActivity = Date.now();
1957
2840
  this.v3StreamingStarted = false;
2841
+ this.v3StreamedTextBuffer = '';
2842
+ this.v3LiveToolEvidence = [];
2843
+ this.v3PendingToolCalls = [];
1958
2844
  const taskDisplay = new TaskDisplay(['Analyse workspace', 'Execute tasks', 'Validate output', 'Self-heal'], !this.jsonOutput);
1959
2845
  taskDisplay.start(0);
1960
2846
  const spinner = this.jsonOutput ? null : createSpinner({
@@ -2018,25 +2904,59 @@ export class ChatCommand {
2018
2904
  const executionPrompt = this.buildExecutionPrompt(contextualPrompt);
2019
2905
  const agentTaskType = this.inferAgentTaskType(contextualPrompt);
2020
2906
  const workspaceContext = {
2021
- workspacePath: this.currentProjectPath,
2022
- projectPath: this.currentProjectPath,
2023
- targetPath: this.currentProjectPath,
2907
+ workspacePath: workspacePath,
2908
+ projectPath: workspacePath,
2909
+ targetPath: workspacePath,
2024
2910
  ...runtimeContext,
2025
2911
  };
2912
+ if (!this.jsonOutput && !this.directPromptMode) {
2913
+ try {
2914
+ const snapshot = this.api.getAgentWorkspaceSnapshot(workspacePath);
2915
+ console.log(chalk.gray(`Workspace sync: ${snapshot.fileCount} files indexed from ${workspacePath}`));
2916
+ if (snapshot.paths.length > 0) {
2917
+ console.log(chalk.gray(`Workspace index: ${snapshot.paths.slice(0, 5).map((filePath) => filePath.replace(/\\/g, '/')).join(', ')}${snapshot.paths.length > 5 ? ', ...' : ''}`));
2918
+ }
2919
+ }
2920
+ catch {
2921
+ console.log(chalk.gray(`Workspace sync: preparing ${workspacePath}`));
2922
+ }
2923
+ }
2026
2924
  // Start workspace watcher for bidirectional real-time sync
2027
2925
  let watcher = null;
2028
- if (this.currentProjectPath && fs.existsSync(this.currentProjectPath)) {
2926
+ let workspaceWs = null;
2927
+ const stopWorkspaceSync = () => {
2928
+ watcher?.stop();
2929
+ if (workspaceWs) {
2930
+ workspaceWs.disconnect();
2931
+ workspaceWs = null;
2932
+ }
2933
+ };
2934
+ if (this.shouldStartWorkspaceWatcher(workspacePath)) {
2029
2935
  watcher = new WorkspaceWatcher({
2030
- workspaceRoot: this.currentProjectPath,
2936
+ workspaceRoot: workspacePath,
2031
2937
  onFileChange: (relativePath, content, action) => {
2032
2938
  this.logger.debug(`Local change detected: ${action} ${relativePath}`);
2939
+ workspaceWs?.syncFile(relativePath, content, action);
2033
2940
  },
2034
2941
  });
2035
2942
  watcher.start();
2036
2943
  }
2037
2944
  try {
2945
+ // Ensure the V3 service key is available before health check
2946
+ await this.api.ensureV3ServiceKey();
2947
+ if (spinner)
2948
+ this.updateV3AgentSpinner(spinner, { type: 'v3_status', content: 'Checking V3 connection (preflight)...' });
2949
+ const healthCheck = await this.api.runV3HealthCheck({ soft: true });
2950
+ if (!healthCheck.healthy) {
2951
+ this.logger.warn('V3 health probe did not confirm readiness; starting SSE agent stream anyway.');
2952
+ if (spinner)
2953
+ this.updateV3AgentSpinner(spinner, { type: 'v3_status', content: 'Starting V3 agent stream...' });
2954
+ }
2955
+ else if (spinner) {
2956
+ this.updateV3AgentSpinner(spinner, { type: 'v3_status', content: 'Connected to V3. Starting agent execution...' });
2957
+ }
2038
2958
  const workflowPromise = this.api.runV3AgentWorkflow(executionPrompt, {
2039
- workspace: { path: this.currentProjectPath },
2959
+ workspace: { path: workspacePath },
2040
2960
  ...workspaceContext,
2041
2961
  agentTaskType,
2042
2962
  executionSurface: 'cli',
@@ -2052,6 +2972,32 @@ export class ChatCommand {
2052
2972
  rawPrompt: prompt,
2053
2973
  contextualPrompt,
2054
2974
  history: this.getMessagesForModel(),
2975
+ clientToolExecution: true,
2976
+ liveToolEvidence: this.v3LiveToolEvidence,
2977
+ onClientToolExecute: (event) => this.executeClientV3Tool(event),
2978
+ onWorkspaceContext: async ({ contextId, serverWorkspaceRoot }) => {
2979
+ if (workspaceWs || !contextId || !serverWorkspaceRoot || !this.shouldStartWorkspaceWatcher(workspacePath)) {
2980
+ return;
2981
+ }
2982
+ const headers = await this.api.getV3AgentHeaders();
2983
+ const token = String(headers.Authorization?.replace(/^Bearer\s+/i, '')
2984
+ || headers['X-V3-Service-Key']
2985
+ || headers['x-v3-service-key']
2986
+ || '').trim();
2987
+ if (!token) {
2988
+ return;
2989
+ }
2990
+ const baseUrl = this.api.getV3AgentBaseUrls(false)[0] || 'https://coder.vigthoria.io';
2991
+ const serverUrl = baseUrl.replace(/^http/i, 'ws');
2992
+ workspaceWs = new WorkspaceWSClient({
2993
+ serverUrl,
2994
+ token,
2995
+ contextId,
2996
+ workspaceRoot: serverWorkspaceRoot,
2997
+ });
2998
+ workspaceWs.connect();
2999
+ this.logger.debug(`Workspace WS sync bound to context ${contextId}`);
3000
+ },
2055
3001
  ...runtimeContext,
2056
3002
  onStreamEvent: (event) => {
2057
3003
  if (event.type === 'plan') {
@@ -2111,6 +3057,34 @@ export class ChatCommand {
2111
3057
  if (this.v3StreamingStarted) {
2112
3058
  process.stdout.write('\n');
2113
3059
  }
3060
+ let finalContent = String(response.content || '').trim();
3061
+ const needsUserReport = this.inferAgentTaskType(prompt) === 'analysis'
3062
+ || this.isThinV3Summary(finalContent)
3063
+ || this.v3LiveToolEvidence.length > 0;
3064
+ if (needsUserReport) {
3065
+ const userReport = this.buildUserFacingV3RunReport(prompt, workspacePath, {
3066
+ partial: response.partial === true || this.isThinV3Summary(finalContent),
3067
+ serverNote: finalContent && !this.isThinV3Summary(finalContent) ? finalContent : undefined,
3068
+ });
3069
+ if (userReport && !this.isThinV3Summary(userReport)) {
3070
+ finalContent = userReport;
3071
+ }
3072
+ }
3073
+ else if (this.isThinV3Summary(finalContent) && this.v3LiveToolEvidence.length > 0) {
3074
+ const evidenceSummary = this.api.formatV3AgentResponse({
3075
+ events: [],
3076
+ liveToolEvidence: this.v3LiveToolEvidence,
3077
+ });
3078
+ if (evidenceSummary && !this.isThinV3Summary(evidenceSummary)) {
3079
+ finalContent = evidenceSummary;
3080
+ }
3081
+ }
3082
+ response.content = finalContent || response.content;
3083
+ let v3UserReportPrinted = false;
3084
+ if (!this.jsonOutput && finalContent && needsUserReport) {
3085
+ this.printV3UserReport(finalContent);
3086
+ v3UserReportPrinted = true;
3087
+ }
2114
3088
  const previewGate = (response.metadata?.previewGate || null);
2115
3089
  const workspaceHasOutput = this.api.hasAgentWorkspaceOutput(workspaceContext);
2116
3090
  const success = previewGate?.required === true
@@ -2123,7 +3097,7 @@ export class ChatCommand {
2123
3097
  }
2124
3098
  this.logger.warn('Falling back to legacy CLI agent loop');
2125
3099
  this.logger.debug(`V3 agent workflow returned an incomplete result: ${previewGate?.error || 'workspace changes were not fully validated'}`);
2126
- watcher?.stop();
3100
+ stopWorkspaceSync();
2127
3101
  return false;
2128
3102
  }
2129
3103
  const errorMessage = `V3 agent workflow returned an incomplete result and legacy fallback is disabled. ${previewGate?.error || 'Workspace changes were not fully validated.'}`;
@@ -2144,7 +3118,7 @@ export class ChatCommand {
2144
3118
  metadata: { executionPath: 'v3-agent', previewGate },
2145
3119
  }, null, 2));
2146
3120
  }
2147
- watcher?.stop();
3121
+ stopWorkspaceSync();
2148
3122
  return true;
2149
3123
  }
2150
3124
  if (!this.jsonOutput && previewGate?.required && previewGate?.passed !== true && workspaceHasOutput) {
@@ -2164,16 +3138,32 @@ export class ChatCommand {
2164
3138
  }, null, 2));
2165
3139
  }
2166
3140
  else if (this.v3StreamingStarted) {
2167
- // Content was already streamed to stdout in real-time; skip duplicate print.
2168
- if (!this.jsonOutput) {
3141
+ // Content may have been streamed already, but some V3 runs only stream
3142
+ // status/tool messages and keep the useful final report in response.content.
3143
+ if (!v3UserReportPrinted && !this.jsonOutput) {
2169
3144
  console.log(chalk.gray(`Agent routing: ${routingPolicy.cloudSelected ? 'Vigthoria Cloud' : 'V3 Agent'}`));
2170
3145
  }
3146
+ if (!v3UserReportPrinted && response.content && this.shouldPrintV3FinalContent(response.content)) {
3147
+ console.log(response.content);
3148
+ }
3149
+ else if (!v3UserReportPrinted && response.content && this.isThinV3Summary(response.content)) {
3150
+ console.log(chalk.yellow('V3 agent finished after reading local files, but no final narrative summary was emitted.'));
3151
+ console.log(chalk.gray('Use /continue and ask for a written overview.'));
3152
+ }
2171
3153
  }
2172
3154
  else if (response.content) {
2173
- if (!this.directPromptMode) {
3155
+ if (!v3UserReportPrinted && !this.directPromptMode) {
2174
3156
  console.log(chalk.gray(`Agent routing: ${routingPolicy.cloudSelected ? 'Vigthoria Cloud' : 'V3 Agent'}`));
2175
3157
  }
2176
- console.log(response.content);
3158
+ if (!v3UserReportPrinted && this.shouldPrintV3FinalContent(response.content)) {
3159
+ console.log(response.content);
3160
+ }
3161
+ else if (!v3UserReportPrinted && this.isThinV3Summary(response.content)) {
3162
+ console.log('V3 agent workflow completed, but no final task summary was emitted.');
3163
+ }
3164
+ else if (!v3UserReportPrinted) {
3165
+ console.log(response.content);
3166
+ }
2177
3167
  }
2178
3168
  else {
2179
3169
  if (!this.directPromptMode) {
@@ -2285,11 +3275,11 @@ export class ChatCommand {
2285
3275
  this.printAgentRunSummary(this.lastAgentRunOutcome, executorSucceeded, changedFileCount);
2286
3276
  }
2287
3277
  this.messages.push({ role: 'assistant', content: response.content || 'V3 agent workflow completed.' });
2288
- watcher?.stop();
3278
+ stopWorkspaceSync();
2289
3279
  return true;
2290
3280
  }
2291
3281
  catch (error) {
2292
- watcher?.stop();
3282
+ stopWorkspaceSync();
2293
3283
  if (!this.api.hasAgentWorkspaceOutput(workspaceContext)) {
2294
3284
  const recovered = await this.tryRecoverV3ServiceAndRetry(executionPrompt, prompt, workspaceContext, routingPolicy, spinner, error);
2295
3285
  if (recovered) {
@@ -2592,9 +3582,15 @@ export class ChatCommand {
2592
3582
  this.syncInteractiveModeModel('agent');
2593
3583
  console.log(chalk.gray('Agent mode re-enabled for continuation.'));
2594
3584
  }
2595
- await this.runAgentTurn(followUp);
3585
+ await this.runAgentTurn(followUp, { skipLocalLoop: true, forceV3: true });
2596
3586
  continue;
2597
3587
  }
3588
+ if (/^(?:\/logout|logout)$/i.test(trimmed)) {
3589
+ const { logout } = await import('./auth.js');
3590
+ await logout();
3591
+ console.log(chalk.gray('Session ended.'));
3592
+ break;
3593
+ }
2598
3594
  if (trimmed === '/save') {
2599
3595
  this.saveSession();
2600
3596
  console.log(chalk.green('Session saved.'));
@@ -2681,6 +3677,9 @@ export class ChatCommand {
2681
3677
  console.log(chalk.gray(' Pending: ') + chalk.yellow(unfinishedList.join(', ')) + more);
2682
3678
  }
2683
3679
  }
3680
+ else if (!executorSucceeded) {
3681
+ console.log(chalk.gray(' The detailed overview is printed above when available.'));
3682
+ }
2684
3683
  if (typeof outcome.qualityScore === 'number') {
2685
3684
  const score = outcome.qualityScore.toFixed(1);
2686
3685
  const colour = outcome.qualityScore >= 70 ? chalk.green : outcome.qualityScore >= 30 ? chalk.yellow : chalk.red;
@@ -2769,7 +3768,10 @@ export class ChatCommand {
2769
3768
  const missingLine = o.qualityMissing.length > 0
2770
3769
  ? `\nMissing pieces: ${o.qualityMissing.slice(0, 6).join(', ')}.`
2771
3770
  : '';
2772
- return `Continue the previous agent run from the current workspace state without re-doing already-completed work.${taskList}${blockerLine}${missingLine}\nOriginal request was: ${o.prompt}`;
3771
+ return `Continue the previous agent run from the current workspace state without re-doing already-completed work.${taskList}${blockerLine}${missingLine}
3772
+ Original request was: ${o.prompt}
3773
+
3774
+ Now implement the missing fixes by editing the local workspace files. Use write/edit tools to apply the required code and HTML changes so the project runs.`;
2773
3775
  }
2774
3776
  /**
2775
3777
  * Re-print the last agent run summary, or guide the user when there isn't one.
@@ -2857,6 +3859,7 @@ export class ChatCommand {
2857
3859
  });
2858
3860
  }
2859
3861
  buildAgentSystemPrompt() {
3862
+ const runtime = this.getRuntimeEnvironmentContext();
2860
3863
  const toolCatalog = AgenticTools.getToolDefinitions()
2861
3864
  .map((tool) => {
2862
3865
  const params = tool.parameters
@@ -2868,6 +3871,7 @@ export class ChatCommand {
2868
3871
  return [
2869
3872
  'Vigthoria CLI agent operating contract.',
2870
3873
  `You are operating inside the project root: ${this.currentProjectPath}`,
3874
+ `Execution platform: ${runtime.platform} (${runtime.osPlatform}). Machine scope: ${runtime.machineScope}.`,
2871
3875
  `You are operating on the user's LOCAL machine. This CLI is not a server-only runtime.`,
2872
3876
  `All file reads/writes and command execution must target the local project/workspace path unless the user explicitly requests remote/server execution.`,
2873
3877
  'For command execution: ask for confirmation before risky commands; never redirect normal local work to server paths.',
@@ -2900,10 +3904,12 @@ export class ChatCommand {
2900
3904
  ].join('\n');
2901
3905
  }
2902
3906
  buildScopedUserPrompt(prompt) {
3907
+ const runtime = this.getRuntimeEnvironmentContext();
2903
3908
  return [
2904
3909
  this.buildExecutionPrompt(prompt),
2905
3910
  '',
2906
3911
  `Project root: ${this.currentProjectPath}`,
3912
+ `Execution platform: ${runtime.platform}. Machine scope: ${runtime.machineScope}.`,
2907
3913
  'Stay within this project root unless the user explicitly expands scope.',
2908
3914
  'Finish the request and stop once it is complete.',
2909
3915
  ].join('\n');
@@ -2965,7 +3971,14 @@ export class ChatCommand {
2965
3971
  return true;
2966
3972
  }
2967
3973
  const extension = path.extname(targetFile).toLowerCase();
2968
- return extension === '';
3974
+ if (extension === '')
3975
+ return true;
3976
+ // HTML/HTM files need V3 agent with the 35B model for quality rewrites (canvas,
3977
+ // styling, interactivity). Route them to tryV3AgentWorkflow instead of the
3978
+ // lightweight cloud-chat direct-file path.
3979
+ if (extension === '.html' || extension === '.htm')
3980
+ return true;
3981
+ return false;
2969
3982
  }
2970
3983
  shouldPreferLocalAgentLoop(prompt) {
2971
3984
  if (this.shouldRequireV3AgentWorkflow(prompt)) {
@@ -2975,16 +3988,44 @@ export class ChatCommand {
2975
3988
  if (forceV3) {
2976
3989
  return false;
2977
3990
  }
3991
+ if (this.isBrowserTaskPrompt(prompt)) {
3992
+ return false;
3993
+ }
2978
3994
  const runtime = this.getRuntimeEnvironmentContext();
2979
- // Local machines and non-server-bindable workspaces should run locally first.
3995
+ const forceRemoteAgent = /^(1|true|yes)$/i.test(String(process.env.VIGTHORIA_PREFER_V3_AGENT_LOOP
3996
+ || process.env.VIGTHORIA_FORCE_REMOTE_AGENT
3997
+ || ''));
3998
+ // CLI sessions on a user's local machine must execute tools locally.
3999
+ // Remote V3 only sees a partial hydrated copy and cannot reach real paths.
4000
+ if (runtime.machineScope === 'local-machine') {
4001
+ if (forceRemoteAgent) {
4002
+ return false;
4003
+ }
4004
+ if (this.isImplementationPrompt(prompt)) {
4005
+ return true;
4006
+ }
4007
+ const preferLocalOptIn = /^(1|true|yes)$/i.test(String(process.env.VIGTHORIA_PREFER_LOCAL_AGENT_LOOP || ''));
4008
+ // Read-only analysis on a Windows/macOS/Linux desktop is faster and more
4009
+ // reliable through V3 + client-side read tools than a blocking chat route.
4010
+ if (this.isAnalysisLookupPrompt(prompt) && !preferLocalOptIn) {
4011
+ return false;
4012
+ }
4013
+ return true;
4014
+ }
4015
+ // Server-bindable workspaces keep V3 as the default execution path.
4016
+ const preferLocalOptIn = /^(1|true|yes)$/i.test(String(process.env.VIGTHORIA_PREFER_LOCAL_AGENT_LOOP || ''));
4017
+ if (!preferLocalOptIn) {
4018
+ return false;
4019
+ }
4020
+ if (this.directPromptMode && this.isRepoGroundedPrompt(prompt)) {
4021
+ return false;
4022
+ }
2980
4023
  if (!runtime.serverBindableWorkspace) {
2981
4024
  return true;
2982
4025
  }
2983
- // Interactive sessions should prioritize local edits unless V3 is explicitly forced.
2984
4026
  if (!this.directPromptMode) {
2985
4027
  return true;
2986
4028
  }
2987
- // For direct prompts on server-bindable Linux roots, keep V3-first behavior.
2988
4029
  return runtime.platform !== 'linux';
2989
4030
  }
2990
4031
  getRuntimeEnvironmentContext() {
@@ -3026,7 +4067,7 @@ export class ChatCommand {
3026
4067
  if (!projectPath || !path.isAbsolute(projectPath) || !fs.existsSync(projectPath)) {
3027
4068
  return false;
3028
4069
  }
3029
- const configuredRoots = (process.env.VIGTHORIA_SERVER_WORKSPACE_ROOTS || '/var/www/vigthoria-user-workspaces,/var/lib/vigthoria-workspaces')
4070
+ const configuredRoots = (process.env.VIGTHORIA_SERVER_WORKSPACE_ROOTS || '/var/www,/var/www/vigthoria-user-workspaces,/var/lib/vigthoria-workspaces')
3030
4071
  .split(',')
3031
4072
  .map((entry) => entry.trim())
3032
4073
  .filter(Boolean);
@@ -3325,7 +4366,11 @@ export class ChatCommand {
3325
4366
  * substantive answer.
3326
4367
  */
3327
4368
  sanitizeDirectModeOutput(text) {
3328
- let cleaned = text;
4369
+ let cleaned = this.stripHiddenThoughtBlocks(text);
4370
+ cleaned = cleaned
4371
+ .replace(/<\|mask_start\|>[\s\S]*?<\|mask_end\|>/g, '')
4372
+ .replace(/<think>[\s\S]*?<\/think>/gi, '')
4373
+ .replace(/<\|(?:mask_start|mask_end)\|>/g, '');
3329
4374
  // ── Phase 1: Strip entire tool-output blocks ──
3330
4375
  // Matches "Tool <name> succeeded/FAILED." through the next blank line,
3331
4376
  // next tool header, or end-of-string. The DOTALL-like [\s\S]*? is
@@ -3398,7 +4443,7 @@ export class ChatCommand {
3398
4443
  cleaned = kept.join('\n\n');
3399
4444
  // Collapse multiple blank lines
3400
4445
  cleaned = cleaned.replace(/\n{3,}/g, '\n\n').trim();
3401
- return cleaned || text;
4446
+ return cleaned || this.stripHiddenThoughtBlocks(text);
3402
4447
  }
3403
4448
  isDirectModeFollowUpQuestion(text) {
3404
4449
  return /^(would you like me|do you want me|which aspect|what aspect|can you clarify|could you clarify|should i focus on|i will follow|i understand|i('ll| will) adhere|provide your|waiting for)/i.test(text.trim());
@@ -3480,6 +4525,57 @@ export class ChatCommand {
3480
4525
  .trim();
3481
4526
  return normalized || null;
3482
4527
  }
4528
+ async tryLocalToolFallback(call, result) {
4529
+ if (result.success || !this.tools) {
4530
+ return result;
4531
+ }
4532
+ const runtime = this.getRuntimeEnvironmentContext();
4533
+ if (runtime.machineScope !== 'local-machine') {
4534
+ return result;
4535
+ }
4536
+ if (call.tool === 'read_file' && call.args.path) {
4537
+ const parent = path.dirname(call.args.path);
4538
+ const listPath = parent === '.' || parent === '' ? '.' : parent;
4539
+ const listResult = await this.tools.execute({
4540
+ tool: 'list_dir',
4541
+ args: { path: listPath },
4542
+ });
4543
+ if (listResult.success && listResult.output) {
4544
+ return {
4545
+ ...result,
4546
+ suggestion: `File not found at "${call.args.path}". Parent directory "${listPath}" contains:\n${listResult.output}`,
4547
+ };
4548
+ }
4549
+ const baseName = path.basename(call.args.path);
4550
+ const globResult = await this.tools.execute({
4551
+ tool: 'glob',
4552
+ args: { pattern: `**/${baseName}` },
4553
+ });
4554
+ if (globResult.success && globResult.output?.trim()) {
4555
+ return {
4556
+ ...result,
4557
+ suggestion: `File not found at "${call.args.path}". Matching paths:\n${globResult.output}`,
4558
+ };
4559
+ }
4560
+ }
4561
+ if (call.tool === 'list_dir') {
4562
+ const listPath = call.args.path || '.';
4563
+ const command = runtime.platform === 'windows'
4564
+ ? `dir /b "${path.join(this.currentProjectPath, listPath)}"`
4565
+ : `ls -la "${path.join(this.currentProjectPath, listPath)}"`;
4566
+ const shellResult = await this.tools.execute({
4567
+ tool: 'bash',
4568
+ args: { command, cwd: this.currentProjectPath },
4569
+ });
4570
+ if (shellResult.success && shellResult.output) {
4571
+ return {
4572
+ ...result,
4573
+ suggestion: `list_dir failed for "${listPath}". Terminal listing:\n${shellResult.output}`,
4574
+ };
4575
+ }
4576
+ }
4577
+ return result;
4578
+ }
3483
4579
  async executeToolCalls(toolCalls) {
3484
4580
  if (!this.tools) {
3485
4581
  throw new Error('Agent tools are not initialized.');
@@ -3489,10 +4585,15 @@ export class ChatCommand {
3489
4585
  const verbose = !this.jsonOutput;
3490
4586
  for (const call of toolCalls) {
3491
4587
  if (verbose) {
3492
- console.log(chalk.cyan(`⚙ Executing: ${call.tool}`));
4588
+ const target = call.args.path || call.args.pattern || call.args.command || '';
4589
+ const detail = target ? chalk.gray(` → ${String(target).replace(/\\/g, '/')}`) : '';
4590
+ console.log(chalk.cyan(`⚙ Executing: ${call.tool}`) + detail);
3493
4591
  }
3494
4592
  getBridgeClient()?.emitToolCall({ tool: call.tool, args: call.args });
3495
4593
  let result = await this.tools.execute(call);
4594
+ if (!result.success) {
4595
+ result = await this.tryLocalToolFallback(call, result);
4596
+ }
3496
4597
  // Phase 2: If a search tool failed (search_failed), retry with alternate approach
3497
4598
  const searchStatus = result.metadata?.searchStatus;
3498
4599
  if (call.tool === 'grep' && searchStatus === 'search_failed') {