vigthoria-cli 1.10.1 → 1.10.37

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,15 @@ 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 { WorkspaceBrainService } from '../utils/workspace-brain-service.js';
15
+ import { buildPersonaOverlay, normalizePersonaMode } from '../utils/persona.js';
14
16
  const DEFAULT_V3_AGENT_TIMEOUT_MS = (() => {
15
17
  const rawValue = process.env.VIGTHORIA_AGENT_TIMEOUT_MS || process.env.V3_AGENT_TIMEOUT_MS;
16
18
  if (!rawValue) {
@@ -22,18 +24,18 @@ const DEFAULT_V3_AGENT_TIMEOUT_MS = (() => {
22
24
  const DEFAULT_V3_AGENT_IDLE_TIMEOUT_MS = (() => {
23
25
  const rawValue = process.env.VIGTHORIA_AGENT_IDLE_TIMEOUT_MS || process.env.V3_AGENT_IDLE_TIMEOUT_MS;
24
26
  if (!rawValue) {
25
- return 0;
27
+ return 90000;
26
28
  }
27
29
  const parsed = Number.parseInt(rawValue, 10);
28
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
30
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 90000;
29
31
  })();
30
32
  const DEFAULT_V3_AGENT_SOFT_TIMEOUT_MS = (() => {
31
33
  const rawValue = process.env.VIGTHORIA_AGENT_SOFT_TIMEOUT_MS || process.env.V3_AGENT_SOFT_TIMEOUT_MS;
32
34
  if (!rawValue) {
33
- return 0;
35
+ return 180000;
34
36
  }
35
37
  const parsed = Number.parseInt(rawValue, 10);
36
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
38
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 180000;
37
39
  })();
38
40
  export class ChatCommand {
39
41
  config;
@@ -43,6 +45,7 @@ export class ChatCommand {
43
45
  tools = null;
44
46
  sessionManager;
45
47
  projectMemory = null;
48
+ workspaceBrain = null;
46
49
  currentSession = null;
47
50
  agentMode = false;
48
51
  currentProjectPath = process.cwd();
@@ -52,6 +55,7 @@ export class ChatCommand {
52
55
  currentModel = 'code';
53
56
  modelExplicitlySelected = false;
54
57
  autoApprove = false;
58
+ personaOverride = null;
55
59
  // Phase 5: Agent quality gate — track tool usage for evidence thresholds
56
60
  agentToolEvidence = { discovery: 0, mutation: 0, searchFailed: 0 };
57
61
  operatorMode = false;
@@ -112,17 +116,22 @@ export class ChatCommand {
112
116
  toUserFacingApiError(error, context) {
113
117
  const classified = classifyError(error);
114
118
  const status = classified.statusCode || (this.isJwtExpirationError(error) ? 401 : 500);
115
- if (this.isJwtExpirationError(error)) {
119
+ if (this.isJwtExpirationError(error) || classified.category === 'auth') {
116
120
  return new CLIError('Your Vigthoria session has expired. Run `vigthoria login` to authenticate again.', 'auth', { statusCode: 401 });
117
121
  }
118
- if (this.isTimeoutError(error)) {
122
+ // Preserve structured API classification first (auth/model/network/etc.)
123
+ // so upstream responses are not relabeled by message heuristics.
124
+ if (classified.category === 'timeout') {
119
125
  return new CLIError(`${context} timed out. Check your connection and try again.`, 'timeout', { statusCode: status });
120
126
  }
121
- if (this.isNetworkError(error)) {
127
+ if (classified.category === 'network') {
122
128
  return new CLIError(`${context} could not reach the Vigthoria API. Check your network connection and try again.`, 'network', { statusCode: status });
123
129
  }
130
+ if (classified.category === 'model_backend') {
131
+ return new CLIError(VIGTHORIA_SERVER_TEMPORARILY_UNAVAILABLE_MESSAGE, 'model_backend', { statusCode: status, endpoint: classified.endpoint });
132
+ }
124
133
  const message = sanitizeUserFacingErrorText(classified.message || `${context} failed`);
125
- return new CLIError(message, status === 401 ? 'auth' : 'model_backend', { statusCode: status });
134
+ return new CLIError(message, 'model_backend', { statusCode: status, endpoint: classified.endpoint });
126
135
  }
127
136
  handleApiError(error, context) {
128
137
  const userFacingError = this.toUserFacingApiError(error, context);
@@ -151,10 +160,6 @@ export class ChatCommand {
151
160
  return await operation();
152
161
  }
153
162
  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
163
  lastError = error;
159
164
  if (this.isJwtExpirationError(error)) {
160
165
  this.handleApiError(error, context);
@@ -163,7 +168,8 @@ export class ChatCommand {
163
168
  this.handleApiError(error, context);
164
169
  }
165
170
  if (!this.jsonOutput) {
166
- this.logger.warn(`${context} failed due to ${this.isTimeoutError(error) ? 'timeout' : 'network error'}; retrying (${attempt + 1}/${retries})...`);
171
+ const transient = this.isTimeoutError(error) ? 'timeout' : this.isNetworkError(error) ? 'network error' : 'temporary service issue';
172
+ this.logger.warn(`${context} failed due to ${transient}; retrying (${attempt + 1}/${retries})...`);
167
173
  }
168
174
  await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1)));
169
175
  }
@@ -262,7 +268,7 @@ export class ChatCommand {
262
268
  explicitModel: true,
263
269
  heavyTask,
264
270
  cloudEligible,
265
- cloudSelected: this.currentModel === 'cloud' || this.currentModel === 'cloud-reason' || this.currentModel === 'ultra',
271
+ cloudSelected: ['cloud', 'cloud-reason', 'ultra', 'cloud-fast', 'cloud-balanced', 'cloud-code', 'cloud-power', 'cloud-maximum'].includes(this.currentModel),
266
272
  routeReason: 'explicit-model-selection',
267
273
  };
268
274
  }
@@ -295,8 +301,19 @@ export class ChatCommand {
295
301
  routeReason: 'default-v3-agent',
296
302
  };
297
303
  }
298
- getMessagesForModel() {
304
+ getMessagesForModel(options) {
299
305
  const messages = [...this.messages];
306
+ const personaOverlay = this.buildActivePersonaOverlay();
307
+ if (personaOverlay && !messages.some((message) => message.role === 'system' && message.content.includes('Optional persona overlay: Wiener Grantler mode.'))) {
308
+ const insertionIndex = messages.findIndex((message) => message.role !== 'system');
309
+ const personaMessage = { role: 'system', content: personaOverlay };
310
+ if (insertionIndex === -1) {
311
+ messages.push(personaMessage);
312
+ }
313
+ else {
314
+ messages.splice(insertionIndex, 0, personaMessage);
315
+ }
316
+ }
300
317
  const memoryContexts = [
301
318
  this.sessionManager.buildMemoryContext(this.currentSession),
302
319
  this.projectMemory?.buildContextForPrompt(this.getLastUserPrompt()) || '',
@@ -318,8 +335,148 @@ export class ChatCommand {
318
335
  messages.splice(insertionIndex, 0, memoryMessage);
319
336
  }
320
337
  }
338
+ const codebaseContext = this.workspaceBrain?.buildCodebaseContext(this.getLastUserPrompt()) || '';
339
+ if (codebaseContext && !messages.some((message) => message.role === 'system' && message.content.includes('Vigthoria codebase index context.'))) {
340
+ const insertionIndex = messages.findIndex((message) => message.role !== 'system');
341
+ const codebaseMessage = { role: 'system', content: codebaseContext };
342
+ if (insertionIndex === -1) {
343
+ messages.push(codebaseMessage);
344
+ }
345
+ else {
346
+ messages.splice(insertionIndex, 0, codebaseMessage);
347
+ }
348
+ }
349
+ if (options?.compact) {
350
+ const compactLimit = 6000;
351
+ const systemMessages = messages.filter((message) => message.role === 'system').map((message) => ({
352
+ ...message,
353
+ content: message.content.length > compactLimit
354
+ ? `${message.content.slice(0, compactLimit)}\n...[trimmed for first local agent turn]`
355
+ : message.content,
356
+ }));
357
+ const lastUser = [...messages].reverse().find((message) => message.role === 'user');
358
+ return lastUser ? [...systemMessages.slice(0, 2), lastUser] : systemMessages.slice(0, 2);
359
+ }
321
360
  return messages;
322
361
  }
362
+ normalizeClientV3ToolPath(rawPath) {
363
+ let normalized = String(rawPath || '.').trim().replace(/\\/g, '/');
364
+ if (!normalized || normalized === '/') {
365
+ return '.';
366
+ }
367
+ normalized = normalized.replace(/^vigthoria:\/\/workspace\/?/i, '');
368
+ normalized = normalized.replace(/^\.\/+/, '');
369
+ normalized = normalized.replace(/^workspace\/?/i, '');
370
+ const workspaceRoot = this.currentProjectPath || process.cwd();
371
+ const workspaceName = path.basename(workspaceRoot);
372
+ if (workspaceName) {
373
+ const workspaceKey = workspaceName.toLowerCase().replace(/[\s_./\\-]+/g, '');
374
+ const parts = normalized.split('/').filter(Boolean);
375
+ if (parts.length > 0) {
376
+ const firstKey = parts[0].toLowerCase().replace(/[\s_./\\-]+/g, '');
377
+ if (firstKey === workspaceKey) {
378
+ normalized = parts.slice(1).join('/') || '.';
379
+ }
380
+ }
381
+ }
382
+ normalized = normalized.replace(/^\/+/, '').replace(/\/+$/, '');
383
+ return normalized || '.';
384
+ }
385
+ resolveClientV3ToolPath(rawPath) {
386
+ const root = this.currentProjectPath || process.cwd();
387
+ const normalized = this.normalizeClientV3ToolPath(rawPath);
388
+ const absoluteTarget = path.resolve(root, normalized === '.' ? '' : normalized);
389
+ if (fs.existsSync(absoluteTarget)) {
390
+ return normalized;
391
+ }
392
+ const parts = normalized.split('/').filter(Boolean);
393
+ if (parts.length === 0) {
394
+ return '.';
395
+ }
396
+ try {
397
+ const entries = fs.readdirSync(root, { withFileTypes: true });
398
+ const firstKey = parts[0].toLowerCase().replace(/[\s_./\\-]+/g, '');
399
+ for (const entry of entries) {
400
+ const entryKey = entry.name.toLowerCase().replace(/[\s_./\\-]+/g, '');
401
+ if (entryKey === firstKey || entryKey.includes(firstKey) || firstKey.includes(entryKey)) {
402
+ const rest = parts.slice(1).join('/');
403
+ const candidate = rest ? `${entry.name}/${rest}` : entry.name;
404
+ if (fs.existsSync(path.resolve(root, candidate))) {
405
+ return candidate.replace(/\\/g, '/');
406
+ }
407
+ if (!rest) {
408
+ return entry.name.replace(/\\/g, '/');
409
+ }
410
+ }
411
+ }
412
+ }
413
+ catch {
414
+ // Ignore unreadable workspace roots during path repair.
415
+ }
416
+ return normalized;
417
+ }
418
+ normalizeClientV3ToolArgs(args) {
419
+ const normalizedArgs = { ...args };
420
+ for (const key of ['path', 'file_path', 'file', 'target']) {
421
+ if (typeof normalizedArgs[key] === 'string' && normalizedArgs[key].trim()) {
422
+ normalizedArgs[key] = this.resolveClientV3ToolPath(normalizedArgs[key]);
423
+ }
424
+ }
425
+ return normalizedArgs;
426
+ }
427
+ async executeClientV3Tool(event) {
428
+ if (!this.tools) {
429
+ return { success: false, output: '', error: 'Local agent tools are not initialized.' };
430
+ }
431
+ const name = String(event.name || '').trim();
432
+ const args = (event.arguments && typeof event.arguments === 'object')
433
+ ? event.arguments
434
+ : {};
435
+ let toolName = name;
436
+ let toolArgs = {};
437
+ for (const [key, value] of Object.entries(args)) {
438
+ toolArgs[key] = value == null ? '' : String(value);
439
+ }
440
+ toolArgs = this.normalizeClientV3ToolArgs(toolArgs);
441
+ if (name === 'list_directory') {
442
+ toolName = 'list_dir';
443
+ toolArgs = {
444
+ path: toolArgs.path || '.',
445
+ ...(toolArgs.recursive === 'true' || toolArgs.recursive === '1' ? { recursive: 'true' } : {}),
446
+ };
447
+ }
448
+ else if (name === 'search_files') {
449
+ toolName = 'grep';
450
+ toolArgs = {
451
+ path: toolArgs.path || '.',
452
+ pattern: toolArgs.pattern || toolArgs.query || '',
453
+ ...(toolArgs.file_pattern ? { includePattern: toolArgs.file_pattern } : {}),
454
+ };
455
+ }
456
+ else if (name === 'read_file') {
457
+ toolName = 'read_file';
458
+ }
459
+ else if (name === 'write_file' || name === 'edit_file') {
460
+ toolName = name;
461
+ }
462
+ if (!toolName) {
463
+ return { success: false, output: '', error: 'Missing V3 client tool name.' };
464
+ }
465
+ const result = await this.tools.execute({ tool: toolName, args: toolArgs });
466
+ return {
467
+ success: result.success === true,
468
+ output: String(result.output || result.message || ''),
469
+ error: result.error ? String(result.error) : '',
470
+ };
471
+ }
472
+ getActivePersonaMode() {
473
+ if (this.personaOverride)
474
+ return this.personaOverride;
475
+ return normalizePersonaMode(this.config.get('persona')) || 'default';
476
+ }
477
+ buildActivePersonaOverlay() {
478
+ return buildPersonaOverlay(this.getActivePersonaMode(), this.getLastUserPrompt());
479
+ }
323
480
  isDiagnosticPrompt(prompt) {
324
481
  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
482
  }
@@ -328,10 +485,270 @@ export class ChatCommand {
328
485
  * question — these should use analysis_only workflow, not full_autonomy.
329
486
  */
330
487
  isAnalysisLookupPrompt(prompt) {
488
+ if (this.isImplementationPrompt(prompt)) {
489
+ return false;
490
+ }
331
491
  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
492
  }
493
+ isImplementationPrompt(prompt) {
494
+ const trimmed = String(prompt || '').trim();
495
+ if (!trimmed)
496
+ return false;
497
+ 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)) {
498
+ return true;
499
+ }
500
+ if (/\bwhat(?:'s|\s+is|\s+are)\s+missing\b/i.test(trimmed)) {
501
+ return true;
502
+ }
503
+ if (/\b(game|html5|pacman|rogue|app|site)\b/i.test(trimmed) && /\b(missing|broken|fix|implement|working|playable)\b/i.test(trimmed)) {
504
+ return true;
505
+ }
506
+ if (/continue the previous agent run/i.test(trimmed) && /\b(implement|fix|missing|blocker|remaining)\b/i.test(trimmed)) {
507
+ return true;
508
+ }
509
+ return false;
510
+ }
511
+ getWindowsPromptPathRoots() {
512
+ const roots = new Set();
513
+ const add = (value) => {
514
+ const raw = String(value || '').trim();
515
+ if (!raw)
516
+ return;
517
+ try {
518
+ const resolved = path.resolve(raw);
519
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
520
+ roots.add(resolved);
521
+ }
522
+ }
523
+ catch {
524
+ // Ignore unreadable discovery roots.
525
+ }
526
+ };
527
+ const home = os.homedir();
528
+ const cwdRoot = path.parse(process.cwd()).root || path.parse(home).root || '';
529
+ add(process.cwd());
530
+ add(home);
531
+ add(path.join(home, 'Desktop'));
532
+ add(path.join(home, 'Documents'));
533
+ add(process.env.USERPROFILE);
534
+ add(process.env.OneDrive);
535
+ add(process.env.OneDriveCommercial);
536
+ add(process.env.OneDriveConsumer);
537
+ if (process.env.OneDrive) {
538
+ add(path.join(process.env.OneDrive, 'Desktop'));
539
+ add(path.join(process.env.OneDrive, 'Documents'));
540
+ }
541
+ if (cwdRoot) {
542
+ add(cwdRoot);
543
+ add(path.join(cwdRoot, 'vigthoria'));
544
+ add(path.join(cwdRoot, 'Vigthoria'));
545
+ }
546
+ return Array.from(roots);
547
+ }
548
+ findPromptDirectoryByName(rawPath) {
549
+ if (os.platform() !== 'win32')
550
+ return null;
551
+ const normalized = String(rawPath || '').trim().replace(/^\/+/, '').replace(/[\\/]+/g, path.sep);
552
+ if (!normalized)
553
+ return null;
554
+ const wantedParts = normalized.split(/[\\/]+/).filter(Boolean);
555
+ const wantedName = wantedParts[wantedParts.length - 1];
556
+ if (!wantedName)
557
+ return null;
558
+ const maxDepth = 3;
559
+ const maxEntries = 2500;
560
+ const roots = this.getWindowsPromptPathRoots();
561
+ for (const root of roots) {
562
+ let visited = 0;
563
+ const direct = path.join(root, normalized);
564
+ try {
565
+ if (fs.existsSync(direct) && fs.statSync(direct).isDirectory()) {
566
+ return direct;
567
+ }
568
+ }
569
+ catch {
570
+ // Continue with bounded search.
571
+ }
572
+ const stack = [{ dir: root, depth: 0 }];
573
+ while (stack.length > 0 && visited < maxEntries) {
574
+ const current = stack.pop();
575
+ if (!current || current.depth > maxDepth)
576
+ continue;
577
+ let entries;
578
+ try {
579
+ entries = fs.readdirSync(current.dir, { withFileTypes: true });
580
+ }
581
+ catch {
582
+ continue;
583
+ }
584
+ visited += entries.length;
585
+ for (const entry of entries) {
586
+ if (!entry.isDirectory())
587
+ continue;
588
+ if (entry.name === 'node_modules' || entry.name === '.git' || entry.name.startsWith('$'))
589
+ continue;
590
+ const fullPath = path.join(current.dir, entry.name);
591
+ if (entry.name.toLowerCase() === wantedName.toLowerCase()) {
592
+ if (wantedParts.length === 1 || fullPath.toLowerCase().endsWith(normalized.toLowerCase())) {
593
+ return fullPath;
594
+ }
595
+ }
596
+ if (current.depth < maxDepth) {
597
+ stack.push({ dir: fullPath, depth: current.depth + 1 });
598
+ }
599
+ }
600
+ }
601
+ }
602
+ return null;
603
+ }
604
+ extractExplicitLocalPath(prompt) {
605
+ const resolveExistingPath = (rawPath) => {
606
+ const trimmed = String(rawPath || '').trim().replace(/^['"]|['"]$/g, '');
607
+ if (!trimmed)
608
+ return null;
609
+ const candidates = [];
610
+ candidates.push(path.resolve(trimmed));
611
+ if (os.platform() === 'win32' && /^\/[A-Za-z0-9._ -]/.test(trimmed)) {
612
+ const withoutLeadingSlash = trimmed.replace(/^\/+/, '');
613
+ const cwdRoot = path.parse(process.cwd()).root || '';
614
+ // Allow prompts like "/Vigthoria Games" to resolve as "C:/Vigthoria/Games".
615
+ const asSegments = withoutLeadingSlash.replace(/\s+/g, path.sep);
616
+ for (const root of this.getWindowsPromptPathRoots()) {
617
+ candidates.push(path.resolve(root, withoutLeadingSlash));
618
+ candidates.push(path.resolve(root, asSegments));
619
+ const segmentParts = asSegments.split(/[\\/]+/).filter(Boolean);
620
+ if (segmentParts.length > 1 && path.basename(root).toLowerCase() === segmentParts[0].toLowerCase()) {
621
+ candidates.push(path.resolve(root, ...segmentParts.slice(1)));
622
+ }
623
+ }
624
+ if (cwdRoot && !candidates.includes(path.resolve(cwdRoot, withoutLeadingSlash))) {
625
+ candidates.push(path.resolve(cwdRoot, withoutLeadingSlash));
626
+ candidates.push(path.resolve(cwdRoot, asSegments));
627
+ }
628
+ }
629
+ for (const candidate of candidates) {
630
+ try {
631
+ if (fs.existsSync(candidate)) {
632
+ return candidate;
633
+ }
634
+ }
635
+ catch {
636
+ // Continue trying other normalized candidates.
637
+ }
638
+ }
639
+ const discovered = this.findPromptDirectoryByName(trimmed);
640
+ if (discovered)
641
+ return discovered;
642
+ return null;
643
+ };
644
+ // Quoted paths first (supports spaces safely).
645
+ const quotedPatterns = [
646
+ /"([A-Za-z]:[\\/][^"\r\n]+)"/,
647
+ /'([A-Za-z]:[\\/][^'\r\n]+)'/,
648
+ /"(\/[^"]+)"/,
649
+ /'(\/[^']+)'/,
650
+ ];
651
+ for (const pattern of quotedPatterns) {
652
+ const match = prompt.match(pattern);
653
+ if (match?.[1]) {
654
+ const resolved = resolveExistingPath(match[1]);
655
+ if (resolved)
656
+ return resolved;
657
+ }
658
+ }
659
+ // Unquoted Windows path with optional spaces, stopping before instruction connectors.
660
+ 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);
661
+ if (windowsMatch?.[1]) {
662
+ const resolved = resolveExistingPath(windowsMatch[1]);
663
+ if (resolved)
664
+ return resolved;
665
+ }
666
+ // Unix-style absolute path with optional spaces (but not URLs).
667
+ if (!/(https?|ftp):\/\//i.test(prompt)) {
668
+ 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);
669
+ if (unixMatch?.[1]) {
670
+ const candidatePath = unixMatch[1]
671
+ .replace(/\s+(and|or|at|in|the|to|for|with|from|by|on)$/i, '')
672
+ .replace(/[.,;!?:)\]]*$/, '')
673
+ .trim();
674
+ if (candidatePath.length > 1) {
675
+ const resolved = resolveExistingPath(candidatePath);
676
+ if (resolved)
677
+ return resolved;
678
+ }
679
+ }
680
+ }
681
+ return null;
682
+ }
683
+ isUnscopedPromptPathOverrideAllowed() {
684
+ return /^(1|true|yes)$/i.test(String(process.env.VIGTHORIA_ALLOW_UNSCOPED_PROMPT_PATHS || ''));
685
+ }
686
+ isPathWithinRoot(candidatePath, rootPath) {
687
+ const candidate = path.resolve(candidatePath);
688
+ const root = path.resolve(rootPath);
689
+ const rel = path.relative(root, candidate);
690
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
691
+ }
692
+ getPromptPathAllowedRoots(baseWorkspace) {
693
+ const roots = new Set();
694
+ const addRoot = (rawValue) => {
695
+ const value = String(rawValue || '').trim();
696
+ if (!value)
697
+ return;
698
+ const resolved = path.resolve(value);
699
+ try {
700
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
701
+ roots.add(resolved);
702
+ }
703
+ }
704
+ catch {
705
+ // ignore invalid or unreadable roots
706
+ }
707
+ };
708
+ addRoot(baseWorkspace);
709
+ addRoot(this.currentProjectPath);
710
+ addRoot(process.cwd());
711
+ addRoot(this.config.get('project')?.rootPath || null);
712
+ const envRootsRaw = String(process.env.VIGTHORIA_ALLOWED_WORKSPACE_ROOTS || '').trim();
713
+ if (envRootsRaw) {
714
+ for (const entry of envRootsRaw.split(path.delimiter)) {
715
+ addRoot(entry);
716
+ }
717
+ }
718
+ return Array.from(roots);
719
+ }
720
+ resolvePromptWorkspacePath(prompt, baseWorkspace) {
721
+ const explicitPath = this.extractExplicitLocalPath(prompt);
722
+ if (!explicitPath) {
723
+ return null;
724
+ }
725
+ const candidate = path.resolve(explicitPath);
726
+ if (this.isUnscopedPromptPathOverrideAllowed()) {
727
+ return candidate;
728
+ }
729
+ const runtime = this.getRuntimeEnvironmentContext();
730
+ // On local-machine CLI sessions, allow explicit absolute paths from user prompts.
731
+ // The path still must exist on disk (validated by extractExplicitLocalPath).
732
+ if (runtime.machineScope === 'local-machine') {
733
+ return candidate;
734
+ }
735
+ const allowedRoots = this.getPromptPathAllowedRoots(baseWorkspace);
736
+ const isAllowed = allowedRoots.some((root) => this.isPathWithinRoot(candidate, root));
737
+ if (isAllowed) {
738
+ return candidate;
739
+ }
740
+ if (!this.jsonOutput) {
741
+ console.log(chalk.yellow(`Ignoring path outside allowed workspace roots: ${candidate}`));
742
+ if (allowedRoots.length > 0) {
743
+ const displayRoots = allowedRoots.map((root) => root.replace(/\\/g, '/')).join(', ');
744
+ console.log(chalk.gray(`Allowed roots: ${displayRoots}`));
745
+ }
746
+ console.log(chalk.gray('To allow unrestricted prompt path overrides, set VIGTHORIA_ALLOW_UNSCOPED_PROMPT_PATHS=1.'));
747
+ }
748
+ return null;
749
+ }
333
750
  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);
751
+ 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
752
  }
336
753
  /**
337
754
  * Returns true when a prompt can be answered directly without the full
@@ -377,13 +794,36 @@ export class ChatCommand {
377
794
  inferAgentTaskType(prompt) {
378
795
  if (this.isDiagnosticPrompt(prompt))
379
796
  return 'debugging';
797
+ if (this.isImplementationPrompt(prompt)) {
798
+ return /\b(game|html5|pacman|rogue|playable)\b/i.test(prompt) ? 'game-build' : 'implementation';
799
+ }
380
800
  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
801
  return 'analysis';
382
802
  return 'implementation';
383
803
  }
804
+ bindPromptWorkspace(prompt) {
805
+ const resolved = this.resolvePromptWorkspacePath(prompt, this.currentProjectPath);
806
+ if (!resolved) {
807
+ return;
808
+ }
809
+ const normalizedResolved = path.resolve(resolved);
810
+ const normalizedCurrent = path.resolve(this.currentProjectPath);
811
+ if (normalizedResolved === normalizedCurrent) {
812
+ return;
813
+ }
814
+ if (!this.jsonOutput) {
815
+ console.log(chalk.cyan(`📁 Workspace from prompt: ${normalizedResolved}`));
816
+ }
817
+ this.currentProjectPath = normalizedResolved;
818
+ this.tools?.setWorkspaceRoot(normalizedResolved);
819
+ this.projectMemory = new ProjectMemoryService(normalizedResolved);
820
+ }
384
821
  buildTaskShapingInstructions(prompt) {
385
822
  const instructions = [];
386
823
  const runtime = this.getRuntimeEnvironmentContext();
824
+ if (runtime.machineScope === 'local-machine') {
825
+ 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.');
826
+ }
387
827
  // Platform-aware routing hints
388
828
  if (runtime.platform === 'windows') {
389
829
  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.');
@@ -452,6 +892,82 @@ export class ChatCommand {
452
892
  // Project Brain memory must not break chat, GoA, or operator execution.
453
893
  }
454
894
  }
895
+ initializeWorkspaceBrain() {
896
+ if (this.isProjectBrainRuntimeDisabled()) {
897
+ return;
898
+ }
899
+ this.workspaceBrain = new WorkspaceBrainService({
900
+ workspacePath: this.currentProjectPath,
901
+ apiBase: String(this.config.get('apiUrl') || 'https://coder.vigthoria.io'),
902
+ getAuthToken: () => this.config.get('authToken'),
903
+ });
904
+ this.tools?.setIndexedCodebaseSearch((query, maxResults) => (this.workspaceBrain?.searchCodebase(query, maxResults) || ''));
905
+ }
906
+ async bootstrapWorkspaceBrain(interactive) {
907
+ if (!this.workspaceBrain) {
908
+ return;
909
+ }
910
+ const promptIfMissing = interactive
911
+ && !this.jsonOutput
912
+ && process.stdin.isTTY
913
+ && !/^(0|false|no)$/i.test(String(process.env.VIGTHORIA_PROMPT_INDEX || '1'));
914
+ const result = await this.workspaceBrain.ensureIndexed({
915
+ promptIfMissing,
916
+ askToIndex: promptIfMissing
917
+ ? async (fileCount, workspaceName) => new Promise((resolve) => {
918
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
919
+ rl.question(chalk.cyan(`Index "${workspaceName}" for Vigthoria Brain? (~${fileCount} source files) [Y/n] `), (answer) => {
920
+ rl.close();
921
+ const normalized = answer.trim().toLowerCase();
922
+ resolve(normalized === 'n' || normalized === 'no' ? 'later' : 'index_now');
923
+ });
924
+ })
925
+ : undefined,
926
+ });
927
+ if (result.indexed && result.fileCount > 0 && !this.jsonOutput) {
928
+ console.log(chalk.gray(`Brain index ready: ${result.fileCount} files, ${result.chunkCount} chunks`));
929
+ }
930
+ else if (result.prompted === 'later' && !this.jsonOutput) {
931
+ console.log(chalk.gray('Workspace indexing skipped. Use /index anytime.'));
932
+ }
933
+ }
934
+ async reindexWorkspaceBrain() {
935
+ if (!this.workspaceBrain) {
936
+ this.initializeWorkspaceBrain();
937
+ }
938
+ if (!this.workspaceBrain) {
939
+ console.log(chalk.yellow('Workspace Brain is disabled.'));
940
+ return;
941
+ }
942
+ const spinner = createSpinner({ text: 'Indexing workspace for Vigthoria Brain...', spinner: 'clock' }).start();
943
+ try {
944
+ const meta = await this.workspaceBrain.reindexWorkspace();
945
+ spinner.stop();
946
+ console.log(chalk.green(`Indexed ${meta.indexedFileCount} files (${meta.totalChunks} chunks). Brain Hub sync attempted.`));
947
+ }
948
+ catch (error) {
949
+ spinner.stop();
950
+ this.logger.error(error instanceof Error ? error.message : String(error));
951
+ }
952
+ }
953
+ showBrainIndexStatus() {
954
+ if (!this.workspaceBrain) {
955
+ console.log(chalk.yellow('Workspace Brain is disabled (VIGTHORIA_NO_BRAIN=1).'));
956
+ return;
957
+ }
958
+ const status = this.workspaceBrain.getStatus();
959
+ console.log();
960
+ console.log(chalk.white('Workspace Brain Index:'));
961
+ console.log(chalk.gray(`Workspace: ${status.workspacePath}`));
962
+ console.log(chalk.gray(`Files indexed: ${status.indexedFileCount}`));
963
+ console.log(chalk.gray(`Chunks: ${status.totalChunks}`));
964
+ if (status.meta?.indexedAt) {
965
+ console.log(chalk.gray(`Last indexed: ${status.meta.indexedAt}`));
966
+ }
967
+ if (status.meta?.indexHash) {
968
+ console.log(chalk.gray(`Index hash: ${status.meta.indexHash}`));
969
+ }
970
+ }
455
971
  async getPromptRuntimeContext(prompt) {
456
972
  const runtimeContext = {
457
973
  agentRuntime: this.getRuntimeEnvironmentContext(),
@@ -460,6 +976,22 @@ export class ChatCommand {
460
976
  if (brainContext) {
461
977
  runtimeContext.vigthoriaBrain = brainContext;
462
978
  }
979
+ if (this.workspaceBrain) {
980
+ const accountBrain = await this.workspaceBrain.fetchAccountBrainContext();
981
+ if (accountBrain) {
982
+ runtimeContext.accountBrainContext = accountBrain;
983
+ }
984
+ const indexStatus = this.workspaceBrain.getStatus();
985
+ runtimeContext.codebaseIndex = {
986
+ indexedFileCount: indexStatus.indexedFileCount,
987
+ totalChunks: indexStatus.totalChunks,
988
+ indexHash: indexStatus.meta?.indexHash || null,
989
+ };
990
+ const indexedContext = this.workspaceBrain.buildCodebaseContext(prompt);
991
+ if (indexedContext) {
992
+ runtimeContext.codebaseContext = indexedContext;
993
+ }
994
+ }
463
995
  if (!this.isBrowserTaskPrompt(prompt)) {
464
996
  return runtimeContext;
465
997
  }
@@ -495,6 +1027,9 @@ export class ChatCommand {
495
1027
  v3ToolCallCount = 0;
496
1028
  v3LastActivity = Date.now();
497
1029
  v3StreamingStarted = false;
1030
+ v3StreamedTextBuffer = '';
1031
+ v3LiveToolEvidence = [];
1032
+ v3PendingToolCalls = [];
498
1033
  /**
499
1034
  * Strip server-internal path prefixes from tool output strings.
500
1035
  * Prevents exposing paths like /var/www/V3-Code-Agent/... to end users.
@@ -502,7 +1037,16 @@ export class ChatCommand {
502
1037
  sanitizeServerPath(text) {
503
1038
  if (!text)
504
1039
  return text;
505
- return sanitizeUserFacingPathText(text);
1040
+ return sanitizeUserFacingPathText(this.stripHiddenThoughtBlocks(text));
1041
+ }
1042
+ stripHiddenThoughtBlocks(text) {
1043
+ if (!text)
1044
+ return text;
1045
+ return text
1046
+ .replace(/<\|mask_start\|>[\s\S]*?<\|mask_end\|>/g, '')
1047
+ .replace(/<think>[\s\S]*?<\/think>/gi, '')
1048
+ .replace(/<\|(?:mask_start|mask_end)\|>/g, '')
1049
+ .trim();
506
1050
  }
507
1051
  describeV3AgentTool(toolName) {
508
1052
  const normalized = String(toolName || '').toLowerCase();
@@ -560,6 +1104,7 @@ export class ChatCommand {
560
1104
  if (!safeText) {
561
1105
  return;
562
1106
  }
1107
+ this.v3StreamedTextBuffer += safeText;
563
1108
  if (!this.v3StreamingStarted) {
564
1109
  this.v3StreamingStarted = true;
565
1110
  spinner.stop();
@@ -572,6 +1117,117 @@ export class ChatCommand {
572
1117
  }
573
1118
  process.stdout.write(safeText);
574
1119
  }
1120
+ isGenericV3AgentContent(text) {
1121
+ const value = String(text || '').trim();
1122
+ return !value || /^(v3 agent workflow completed\.?|task completed|agent run finished|workflow completed\.?)$/i.test(value);
1123
+ }
1124
+ hasAlreadyStreamedV3Content(text) {
1125
+ const value = String(text || '').trim();
1126
+ if (!value)
1127
+ return true;
1128
+ return this.v3StreamedTextBuffer.includes(value) || value.includes(this.v3StreamedTextBuffer.trim());
1129
+ }
1130
+ isThinV3Summary(text) {
1131
+ const value = String(text || '').trim();
1132
+ if (!value)
1133
+ return true;
1134
+ if (/^#\s*Workspace overview/m.test(value) || /^## Workspace analysis \(from local file inspection\)/m.test(value)) {
1135
+ return false;
1136
+ }
1137
+ if (this.isGenericV3AgentContent(value))
1138
+ return true;
1139
+ if (/^The V3 agent finished without emitting a dedicated final answer/i.test(value)) {
1140
+ return !/(## Files read|## Directories inspected|### )/i.test(value);
1141
+ }
1142
+ return value.length < 120;
1143
+ }
1144
+ shouldPrintV3FinalContent(text) {
1145
+ if (!text || this.isGenericV3AgentContent(text)) {
1146
+ return false;
1147
+ }
1148
+ const value = text.trim();
1149
+ if (/^## Workspace analysis \(from local file inspection\)/m.test(value)) {
1150
+ return true;
1151
+ }
1152
+ if (/Reconstructed task summary|Workspace analysis \(from local file inspection\)/i.test(value)) {
1153
+ return true;
1154
+ }
1155
+ if (!this.v3StreamingStarted) {
1156
+ return true;
1157
+ }
1158
+ return !this.hasAlreadyStreamedV3Content(value);
1159
+ }
1160
+ rememberV3ToolEvidence(event, args = {}) {
1161
+ const output = typeof event?.output === 'string' ? event.output.trim() : '';
1162
+ const errorText = typeof event?.error === 'string' ? event.error.trim() : '';
1163
+ const combined = output || errorText;
1164
+ if (!combined) {
1165
+ return;
1166
+ }
1167
+ const target = String(args.path || args.file_path || args.file || args.target || event?.target || '').trim();
1168
+ this.v3LiveToolEvidence.push({
1169
+ name: String(event?.name || event?.tool || 'unknown_tool'),
1170
+ target: target || undefined,
1171
+ arguments: args,
1172
+ output: combined,
1173
+ success: event?.success !== false,
1174
+ });
1175
+ }
1176
+ buildUserFacingV3RunReport(prompt, workspacePath, options = {}) {
1177
+ const evidenceBody = this.api.formatV3AgentResponse({
1178
+ events: [],
1179
+ liveToolEvidence: this.v3LiveToolEvidence,
1180
+ });
1181
+ const successes = this.v3LiveToolEvidence.filter((entry) => entry.success !== false);
1182
+ const failures = this.v3LiveToolEvidence.filter((entry) => entry.success === false);
1183
+ if (!evidenceBody && successes.length === 0 && failures.length === 0) {
1184
+ return [
1185
+ '# Agent run summary',
1186
+ '',
1187
+ `**Workspace:** ${workspacePath}`,
1188
+ `**Your request:** ${prompt.trim()}`,
1189
+ '',
1190
+ 'The agent finished without successfully reading local files, so no grounded overview could be built.',
1191
+ options.serverNote ? `\n**Server note:** ${options.serverNote}` : '',
1192
+ '',
1193
+ 'Try `/continue` with: "Read Vigthoria-dominion/package.json, game.js, src/Game.js, and src/factions/, then write a full overview."',
1194
+ ].filter(Boolean).join('\n');
1195
+ }
1196
+ const lines = [
1197
+ '# Workspace overview',
1198
+ '',
1199
+ `**Workspace:** ${workspacePath}`,
1200
+ `**Your request:** ${prompt.trim()}`,
1201
+ options.partial
1202
+ ? '**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.'
1203
+ : '**Status:** Complete — summary rebuilt from local file inspection.',
1204
+ '',
1205
+ evidenceBody || '_No readable file excerpts were captured._',
1206
+ ];
1207
+ if (failures.length > 0) {
1208
+ const uniqueFails = new Map();
1209
+ for (const entry of failures) {
1210
+ const failPath = entry.target || entry.arguments?.path || entry.name;
1211
+ if (!uniqueFails.has(failPath)) {
1212
+ uniqueFails.set(failPath, entry.output.split('\n').find(Boolean)?.slice(0, 140) || entry.output.slice(0, 140));
1213
+ }
1214
+ }
1215
+ if (uniqueFails.size > 0 && !/## Paths not found/i.test(evidenceBody || '')) {
1216
+ 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._');
1217
+ }
1218
+ }
1219
+ lines.push('', '---', 'Use `/continue` if you want a longer narrative summary or a deep dive into a specific subfolder.');
1220
+ return lines.join('\n');
1221
+ }
1222
+ printV3UserReport(report) {
1223
+ const value = String(report || '').trim();
1224
+ if (!value || this.isGenericV3AgentContent(value)) {
1225
+ return;
1226
+ }
1227
+ console.log();
1228
+ console.log(value);
1229
+ console.log();
1230
+ }
575
1231
  updateV3AgentSpinner(spinner, event) {
576
1232
  if (this.isRawV3StreamPayload(event)) {
577
1233
  this.consumeV3StreamPayload(spinner, event).catch((error) => {
@@ -589,7 +1245,7 @@ export class ChatCommand {
589
1245
  if (event.type === 'tool_call') {
590
1246
  this.v3ToolCallCount += 1;
591
1247
  const toolDesc = this.describeV3AgentTool(event.tool || event.name || event.tool_name);
592
- const toolTarget = event.arguments?.path || event.arguments?.file_path || event.arguments?.pattern || '';
1248
+ const toolTarget = event.arguments?.path || event.arguments?.file_path || event.arguments?.pattern || event.arguments?.query || '';
593
1249
  const sanitizedTarget = this.sanitizeServerPath(String(toolTarget));
594
1250
  const shortTarget = sanitizedTarget ? ` → ${sanitizedTarget.replace(/\\/g, '/').split('/').slice(-2).join('/')}` : '';
595
1251
  const stepLabel = chalk.cyan(` [${this.v3IterationCount}/${this.v3ToolCallCount}]`) + ` ${toolDesc}${shortTarget}`;
@@ -599,6 +1255,21 @@ export class ChatCommand {
599
1255
  // Show extra detail for key tools
600
1256
  const args = event.arguments || {};
601
1257
  const toolName = event.name || event.tool || '';
1258
+ this.v3PendingToolCalls.push({
1259
+ name: String(toolName || 'unknown_tool'),
1260
+ args: args && typeof args === 'object' ? args : {},
1261
+ });
1262
+ if (toolName === 'search_files') {
1263
+ const pattern = String(args.pattern || args.query || '').trim();
1264
+ const searchPath = String(args.path || '.').trim();
1265
+ process.stderr.write(chalk.gray(` search: ${pattern || '(empty)'} in ${this.sanitizeServerPath(searchPath)}\n`));
1266
+ }
1267
+ else if (toolName === 'list_directory') {
1268
+ process.stderr.write(chalk.gray(` list: ${this.sanitizeServerPath(String(args.path || '.'))}${args.recursive ? ' (recursive)' : ''}\n`));
1269
+ }
1270
+ else if (toolName === 'read_file') {
1271
+ process.stderr.write(chalk.gray(` read: ${this.sanitizeServerPath(String(args.path || args.file_path || ''))}\n`));
1272
+ }
602
1273
  if ((toolName === 'write_file' || toolName === 'edit_file') && typeof args.content === 'string') {
603
1274
  const len = args.content.length;
604
1275
  process.stderr.write(chalk.gray(` ${len > 1000 ? Math.round(len / 1024) + ' KB' : len + ' bytes'} content\n`));
@@ -621,25 +1292,51 @@ export class ChatCommand {
621
1292
  // Show output for failures, or brief summary for successes — sanitize server paths
622
1293
  const rawOutput = typeof event.output === 'string' ? event.output.trim() : '';
623
1294
  const output = this.sanitizeServerPath(rawOutput);
1295
+ const pendingIndex = this.v3PendingToolCalls.findIndex((call) => call.name === toolName);
1296
+ const pendingCall = pendingIndex >= 0
1297
+ ? this.v3PendingToolCalls.splice(pendingIndex, 1)[0]
1298
+ : this.v3PendingToolCalls.shift();
1299
+ this.rememberV3ToolEvidence(event, pendingCall?.args || {});
624
1300
  if (!success && output) {
625
1301
  const sanitizedError = this.sanitizeServerPath(typeof event.error === 'string' ? event.error : output);
626
1302
  const lines = sanitizedError.split('\n').slice(0, 4);
627
1303
  process.stderr.write(chalk.red(` ${lines.join('\n ')}\n`));
628
1304
  }
629
1305
  else if (success && output && output.length > 0) {
630
- const brief = output.split('\n')[0].slice(0, 120);
1306
+ const outputLines = output.split('\n').map((line) => line.trimEnd()).filter(Boolean);
1307
+ const firstMeaningfulLine = outputLines.find((line) => !/^## Untrusted workspace data boundary/i.test(line)) || outputLines[0] || '';
1308
+ const brief = firstMeaningfulLine.slice(0, 160);
631
1309
  process.stderr.write(chalk.gray(` ${brief}${output.length > 120 ? '…' : ''}\n`));
632
1310
  }
633
1311
  spinner.start();
634
1312
  spinner.text = 'Next step...';
635
1313
  return;
636
1314
  }
1315
+ if (event.type === 'v3_status') {
1316
+ spinner.text = this.sanitizeServerPath(String(event.content || 'Routing to V3 Agent...'));
1317
+ return;
1318
+ }
1319
+ if (event.type === 'queued') {
1320
+ if (spinner.isSpinning)
1321
+ spinner.stop();
1322
+ process.stderr.write(chalk.yellow(' [Queue] ') + this.sanitizeServerPath(String(event.message || 'Waiting for V3 capacity...')) + '\n');
1323
+ spinner.start();
1324
+ spinner.text = 'Waiting for V3...';
1325
+ return;
1326
+ }
1327
+ if (event.type === 'heartbeat') {
1328
+ spinner.text = 'V3 Agent is still thinking...';
1329
+ return;
1330
+ }
637
1331
  if (event.type === 'thinking') {
638
1332
  this.v3IterationCount += 1;
639
1333
  const iterText = this.sanitizeServerPath(event.content || '');
640
1334
  if (spinner.isSpinning)
641
1335
  spinner.stop();
642
- process.stderr.write(chalk.cyan(`\n── ${iterText || `Iteration ${this.v3IterationCount}`} ──\n`));
1336
+ process.stderr.write(chalk.cyan(`\n── Iteration ${this.v3IterationCount}${iterText ? '' : '...'} ──\n`));
1337
+ if (iterText) {
1338
+ process.stderr.write(chalk.gray(`${iterText}\n`));
1339
+ }
643
1340
  spinner.start();
644
1341
  spinner.text = 'Analyzing...';
645
1342
  return;
@@ -813,6 +1510,22 @@ export class ChatCommand {
813
1510
  process.stderr.write(chalk.cyan(' [Start] ') + 'Agent initialized\n');
814
1511
  spinner.start();
815
1512
  spinner.text = 'Working...';
1513
+ return;
1514
+ }
1515
+ const fallbackStatus = typeof event.status === 'string' ? this.sanitizeServerPath(event.status) : '';
1516
+ const fallbackStage = typeof event.stage === 'string' ? this.sanitizeServerPath(event.stage) : '';
1517
+ const fallbackMessage = typeof event.message === 'string'
1518
+ ? this.sanitizeServerPath(event.message)
1519
+ : (typeof event.content === 'string' ? this.sanitizeServerPath(event.content) : '');
1520
+ if (event.type === 'status' || event.type === 'progress' || fallbackStatus || fallbackStage) {
1521
+ if (spinner.isSpinning)
1522
+ spinner.stop();
1523
+ if (fallbackMessage) {
1524
+ process.stderr.write(chalk.cyan(' [V3] ') + `${fallbackMessage}\n`);
1525
+ }
1526
+ spinner.start();
1527
+ spinner.text = fallbackStatus || fallbackStage || 'Working...';
1528
+ return;
816
1529
  }
817
1530
  }
818
1531
  updateOperatorSpinner(spinner, event) {
@@ -878,6 +1591,7 @@ export class ChatCommand {
878
1591
  }
879
1592
  return;
880
1593
  }
1594
+ await this.config.refreshHubModelPreferences().catch(() => null);
881
1595
  this.agentMode = options.agent === true;
882
1596
  this.operatorMode = options.operator === true;
883
1597
  this.workflowTarget = typeof options.workflow === 'string' && options.workflow.trim()
@@ -886,6 +1600,7 @@ export class ChatCommand {
886
1600
  this.savePlanToVigFlow = options.savePlan === true;
887
1601
  this.jsonOutput = options.json === true;
888
1602
  this.autoApprove = options.autoApprove === true || this.jsonOutput;
1603
+ this.personaOverride = options.grant === true ? 'wiener_grant' : null;
889
1604
  this.modelExplicitlySelected = Boolean(String(options.model || '').trim());
890
1605
  this.currentModel = this.resolveInitialModel(options);
891
1606
  this.applyNoAgentGovernance(String(options.model || this.currentModel || ''));
@@ -898,6 +1613,8 @@ export class ChatCommand {
898
1613
  this.directToolContinuationCount = 0;
899
1614
  this.tools = new AgenticTools(this.logger, this.currentProjectPath, async (action) => this.requestPermission(action), this.autoApprove);
900
1615
  this.initializeSession(options.resume === true);
1616
+ this.initializeWorkspaceBrain();
1617
+ await this.bootstrapWorkspaceBrain(!options.prompt);
901
1618
  // ── Commando Bridge: connect if --bridge was specified ──────────
902
1619
  if (options.bridge) {
903
1620
  const bridgeClient = new BridgeClient({
@@ -956,7 +1673,10 @@ export class ChatCommand {
956
1673
  bridge.destroy();
957
1674
  }
958
1675
  }
959
- return;
1676
+ // Force-exit: undici + chokidar + HTTPS pool keep the Node.js event
1677
+ // loop alive indefinitely in direct prompt mode; a clean exit is safe here.
1678
+ this.api.destroy();
1679
+ process.exit(process.exitCode ?? 0);
960
1680
  }
961
1681
  await this.startInteractiveChat();
962
1682
  const bridge = getBridgeClient();
@@ -998,11 +1718,57 @@ export class ChatCommand {
998
1718
  }
999
1719
  fs.mkdirSync(this.currentProjectPath, { recursive: true });
1000
1720
  }
1721
+ shouldStartWorkspaceWatcher(workspaceRoot) {
1722
+ if (!workspaceRoot) {
1723
+ return false;
1724
+ }
1725
+ const resolved = path.resolve(workspaceRoot);
1726
+ if (!fs.existsSync(resolved)) {
1727
+ return false;
1728
+ }
1729
+ let stat;
1730
+ try {
1731
+ stat = fs.statSync(resolved);
1732
+ }
1733
+ catch {
1734
+ return false;
1735
+ }
1736
+ if (!stat.isDirectory()) {
1737
+ return false;
1738
+ }
1739
+ const homeDir = path.resolve(os.homedir());
1740
+ const normalized = resolved.replace(/\\/g, '/');
1741
+ const normalizedHome = homeDir.replace(/\\/g, '/');
1742
+ // Guardrail: do not watch broad home/root scopes on interactive shells.
1743
+ if (normalized.toLowerCase() === normalizedHome.toLowerCase()) {
1744
+ if (!this.jsonOutput) {
1745
+ console.log(chalk.gray('Info: workspace watcher disabled for home directory scope.'));
1746
+ }
1747
+ return false;
1748
+ }
1749
+ if (/^[a-zA-Z]:\/$/.test(normalized) || normalized === '/') {
1750
+ if (!this.jsonOutput) {
1751
+ console.log(chalk.gray('Info: workspace watcher disabled for filesystem root scope.'));
1752
+ }
1753
+ return false;
1754
+ }
1755
+ return true;
1756
+ }
1001
1757
  resolveProjectPath(options) {
1002
1758
  const requestedProject = String(options.project || '').trim();
1003
1759
  if (requestedProject) {
1004
1760
  return path.resolve(requestedProject);
1005
1761
  }
1762
+ // Check if prompt contains an explicit local path provided by the user
1763
+ if (options.prompt) {
1764
+ const explicitPath = this.resolvePromptWorkspacePath(options.prompt, process.cwd());
1765
+ if (explicitPath) {
1766
+ if (!this.jsonOutput) {
1767
+ console.log(chalk.gray(`📁 Using project path from prompt: ${explicitPath}`));
1768
+ }
1769
+ return explicitPath;
1770
+ }
1771
+ }
1006
1772
  if (this.shouldUseManagedWorkspace(options)) {
1007
1773
  const rootPath = this.getManagedWorkspaceRoot();
1008
1774
  fs.mkdirSync(rootPath, { recursive: true });
@@ -1465,6 +2231,26 @@ export class ChatCommand {
1465
2231
  }
1466
2232
  }
1467
2233
  async runSimplePrompt(prompt) {
2234
+ if (!this.directPromptMode && !this.operatorMode) {
2235
+ const promptToRun = this.isConfirmationFollowUp(prompt) && this.lastActionableUserInput && this.isRepoGroundedPrompt(this.lastActionableUserInput)
2236
+ ? this.lastActionableUserInput
2237
+ : prompt;
2238
+ if (this.isRepoGroundedPrompt(promptToRun) || /\b(build|implement|complete|fix|create|make|edit|write|change|finish)\b/i.test(promptToRun)) {
2239
+ if (!this.tools) {
2240
+ throw new Error('Agent tools are not initialized.');
2241
+ }
2242
+ this.agentMode = true;
2243
+ this.syncInteractiveModeModel('agent');
2244
+ if (this.currentSession) {
2245
+ this.currentSession.agentMode = true;
2246
+ this.currentSession.model = this.currentModel;
2247
+ }
2248
+ console.log(chalk.yellow('This request needs file access, so I am switching to Agent mode and working in this workspace now.'));
2249
+ console.log(chalk.gray('You do not need to confirm with "yes"; I will continue until the agent run finishes or reports a blocker.'));
2250
+ await this.runAgentTurn(promptToRun);
2251
+ return;
2252
+ }
2253
+ }
1468
2254
  this.lastActionableUserInput = prompt;
1469
2255
  // For direct --prompt mode with simple prompts, use a minimal system
1470
2256
  // message to avoid polluting the response with tool/platform context.
@@ -1525,7 +2311,7 @@ export class ChatCommand {
1525
2311
  const response = await this.callApi('Send chat message', () => this.api.chat(this.getMessagesForModel(), this.currentModel));
1526
2312
  if (spinner)
1527
2313
  spinner.stop();
1528
- const finalText = (response.message || '').trim();
2314
+ const finalText = this.sanitizeDirectModeOutput(this.stripHiddenThoughtBlocks(response.message || '')).trim();
1529
2315
  const effectiveModel = String(response.model || this.currentModel);
1530
2316
  const metadata = this.modelGovernanceFallback
1531
2317
  ? { modelFallback: this.modelGovernanceFallback }
@@ -1570,21 +2356,32 @@ export class ChatCommand {
1570
2356
  }
1571
2357
  }
1572
2358
  }
1573
- async runAgentTurn(prompt) {
2359
+ async runAgentTurn(prompt, options = {}) {
1574
2360
  if (!this.tools) {
1575
2361
  throw new Error('Agent tools are not initialized.');
1576
2362
  }
2363
+ this.bindPromptWorkspace(prompt);
1577
2364
  const requiresV3Workflow = this.shouldRequireV3AgentWorkflow(prompt);
1578
2365
  const handledByDirectFileFlow = await this.tryDirectSingleFileFlow(prompt);
1579
2366
  if (handledByDirectFileFlow) {
1580
2367
  this.saveSession();
1581
2368
  return;
1582
2369
  }
1583
- if (this.shouldPreferLocalAgentLoop(prompt)) {
1584
- await this.runLocalAgentLoop(prompt);
1585
- return;
2370
+ // Prime the message context with the target file when the direct-file flow was
2371
+ // bypassed (e.g. HTML files routed to V3) so the local agent loop has file
2372
+ // awareness and the ⚙ Executing: read_file banner is always emitted.
2373
+ await this.primeBypassedTargetFileContext(prompt);
2374
+ if (!options.skipLocalLoop && !options.forceV3 && this.shouldPreferLocalAgentLoop(prompt)) {
2375
+ const completed = await this.runLocalAgentLoop(prompt);
2376
+ if (completed) {
2377
+ this.saveSession();
2378
+ return;
2379
+ }
2380
+ if (!this.jsonOutput) {
2381
+ console.log(chalk.yellow('Local chat backend unavailable (self-hosted inference may be offline). Trying V3 agent workflow...'));
2382
+ }
1586
2383
  }
1587
- const handledByV3Workflow = await this.tryV3AgentWorkflow(prompt);
2384
+ const handledByV3Workflow = await this.tryV3AgentWorkflow(prompt, options);
1588
2385
  if (handledByV3Workflow) {
1589
2386
  this.saveSession();
1590
2387
  return;
@@ -1598,24 +2395,110 @@ export class ChatCommand {
1598
2395
  }
1599
2396
  await this.runLocalAgentLoop(prompt);
1600
2397
  }
2398
+ localAgentIterationCount = 0;
2399
+ buildLocalAgentChatOptions(preflight, onRouteAttempt) {
2400
+ const preferredRoute = preflight?.endpoint
2401
+ ? this.api.mapPreflightEndpointToRoute(preflight.endpoint)
2402
+ : undefined;
2403
+ return {
2404
+ fastFail: true,
2405
+ singleRoute: true,
2406
+ stream: true,
2407
+ connectTimeoutMs: Number.parseInt(process.env.VIGTHORIA_CHAT_CONNECT_TIMEOUT_MS || '20000', 10),
2408
+ idleTimeoutMs: Number.parseInt(process.env.VIGTHORIA_CHAT_IDLE_TIMEOUT_MS || '120000', 10),
2409
+ preferredRoute: preferredRoute || 'coder',
2410
+ onRouteAttempt,
2411
+ };
2412
+ }
2413
+ printChatModelPreflight(preflight) {
2414
+ if (this.jsonOutput) {
2415
+ return;
2416
+ }
2417
+ console.log(chalk.gray('Model backend preflight:'));
2418
+ for (const route of preflight.routes) {
2419
+ const marker = route.ok ? chalk.green(' ✓') : chalk.red(' ✗');
2420
+ const detail = route.error ? chalk.gray(` — ${route.error}`) : '';
2421
+ console.log(`${marker} ${route.name}${detail}`);
2422
+ }
2423
+ if (preflight.healthy) {
2424
+ console.log(chalk.gray(`Using: ${preflight.endpoint}`));
2425
+ }
2426
+ else if (preflight.error) {
2427
+ console.log(chalk.yellow(preflight.error));
2428
+ }
2429
+ console.log();
2430
+ }
1601
2431
  async runLocalAgentLoop(prompt) {
1602
2432
  if (!this.tools) {
1603
2433
  throw new Error('Agent tools are not initialized.');
1604
2434
  }
2435
+ this.localAgentIterationCount = 0;
2436
+ const runtime = this.getRuntimeEnvironmentContext();
2437
+ if (!this.jsonOutput) {
2438
+ console.log();
2439
+ console.log(chalk.gray('━━━ ROUTING DECISION ━━━'));
2440
+ console.log(chalk.gray('Reason: local-machine-agent-loop'));
2441
+ console.log(chalk.gray(`Platform: ${runtime.platform}`));
2442
+ console.log(chalk.gray(`Machine scope: ${runtime.machineScope}`));
2443
+ console.log(chalk.gray(`Workspace: ${runtime.workspacePath}`));
2444
+ console.log(chalk.gray('Tools execute on your local filesystem.'));
2445
+ console.log(chalk.gray('━'.repeat(30)));
2446
+ console.log();
2447
+ }
1605
2448
  this.lastActionableUserInput = prompt;
1606
2449
  this.directToolContinuationCount = 0;
1607
2450
  this.agentToolEvidence = { discovery: 0, mutation: 0, searchFailed: 0 };
1608
2451
  this.tools.clearSessionApprovals();
1609
2452
  getBridgeClient()?.emitPrompt({ prompt, mode: this.operatorMode ? 'operator' : 'agent', model: this.currentModel });
1610
2453
  this.ensureAgentSystemPrompt();
1611
- this.messages.push({ role: 'user', content: this.buildScopedUserPrompt(prompt) });
2454
+ const scopedPrompt = this.buildScopedUserPrompt(this.buildContextualAgentPrompt(prompt));
2455
+ this.messages.push({ role: 'user', content: scopedPrompt });
2456
+ await this.primeAgentWorkspaceDiscovery(prompt);
1612
2457
  this.saveSession();
2458
+ const preflightSpinner = this.jsonOutput
2459
+ ? null
2460
+ : createSpinner({ text: 'Checking model connection (preflight)...', spinner: 'clock' }).start();
2461
+ let preflight;
2462
+ try {
2463
+ preflight = await this.api.runChatModelPreflight(this.currentModel);
2464
+ }
2465
+ finally {
2466
+ if (preflightSpinner) {
2467
+ preflightSpinner.stop();
2468
+ }
2469
+ }
2470
+ this.printChatModelPreflight(preflight);
2471
+ if (!preflight.healthy) {
2472
+ return false;
2473
+ }
1613
2474
  const maxTurns = 10;
2475
+ const preferredRoute = preflight?.endpoint
2476
+ ? (this.api.mapPreflightEndpointToRoute(preflight.endpoint) || 'coder')
2477
+ : 'coder';
1614
2478
  for (let turn = 0; turn < maxTurns; turn += 1) {
1615
- const spinner = this.jsonOutput ? null : createSpinner({ text: turn === 0 ? 'Planning...' : 'Continuing...', spinner: 'clock' }).start();
2479
+ const spinner = this.jsonOutput ? null : createSpinner({ text: turn === 0 ? 'Planning first tool step...' : 'Continuing...', spinner: 'clock' }).start();
2480
+ let streamedVisible = '';
1616
2481
  let response;
1617
2482
  try {
1618
- response = await this.callApi('Send agent chat message', () => this.api.chat(this.getMessagesForModel(), this.currentModel), 0);
2483
+ response = await this.callApi('Send agent chat message', () => this.api.chat(this.getMessagesForModel({ compact: turn === 0 }), this.currentModel, false, {
2484
+ fastFail: true,
2485
+ singleRoute: true,
2486
+ stream: true,
2487
+ connectTimeoutMs: Number.parseInt(process.env.VIGTHORIA_CHAT_CONNECT_TIMEOUT_MS || '20000', 10),
2488
+ idleTimeoutMs: Number.parseInt(process.env.VIGTHORIA_CHAT_IDLE_TIMEOUT_MS || '120000', 10),
2489
+ preferredRoute,
2490
+ onRouteAttempt: (routeLabel) => {
2491
+ if (spinner) {
2492
+ spinner.text = routeLabel;
2493
+ }
2494
+ },
2495
+ onStreamDelta: (chunk) => {
2496
+ streamedVisible += chunk;
2497
+ if (spinner) {
2498
+ spinner.text = streamedVisible.trim().slice(-72) || 'Streaming model output...';
2499
+ }
2500
+ },
2501
+ }), 0);
1619
2502
  }
1620
2503
  catch (firstErr) {
1621
2504
  // If we already gathered evidence and the model API fails on a
@@ -1624,7 +2507,14 @@ export class ChatCommand {
1624
2507
  this.logger.debug('Agent continuation API call failed, retrying once...');
1625
2508
  try {
1626
2509
  await new Promise(r => setTimeout(r, 2000));
1627
- response = await this.callApi('Retry agent chat message', () => this.api.chat(this.getMessagesForModel(), this.currentModel), 0);
2510
+ response = await this.callApi('Retry agent chat message', () => this.api.chat(this.getMessagesForModel(), this.currentModel, false, {
2511
+ fastFail: true,
2512
+ singleRoute: true,
2513
+ stream: true,
2514
+ connectTimeoutMs: Number.parseInt(process.env.VIGTHORIA_CHAT_CONNECT_TIMEOUT_MS || '20000', 10),
2515
+ idleTimeoutMs: Number.parseInt(process.env.VIGTHORIA_CHAT_IDLE_TIMEOUT_MS || '120000', 10),
2516
+ preferredRoute,
2517
+ }), 0);
1628
2518
  }
1629
2519
  catch (retryErr) {
1630
2520
  // Retry also failed — synthesize an answer from evidence
@@ -1647,13 +2537,57 @@ export class ChatCommand {
1647
2537
  console.log(fallbackContent);
1648
2538
  }
1649
2539
  this.saveSession();
1650
- return;
2540
+ return true;
1651
2541
  }
1652
2542
  throw retryErr;
1653
2543
  }
1654
2544
  }
1655
2545
  else {
1656
- throw firstErr;
2546
+ const cliErr = firstErr instanceof CLIError ? firstErr : classifyError(firstErr);
2547
+ const formatted = formatCLIError(cliErr);
2548
+ if (spinner)
2549
+ spinner.stop();
2550
+ this.rememberBrainEvent('issue', 'Agent model request failed: ' + formatted, 'agent');
2551
+ if (turn === 0 && (cliErr.category === 'model_backend' || cliErr.category === 'network' || cliErr.category === 'timeout')) {
2552
+ if (!this.jsonOutput) {
2553
+ this.logger.error(formatted);
2554
+ const transport = this.api.getLastChatTransportErrors();
2555
+ if (transport.length > 0) {
2556
+ console.log(chalk.gray(`Routes tried: ${transport.slice(0, 3).join(' | ')}`));
2557
+ }
2558
+ }
2559
+ else {
2560
+ process.exitCode = 1;
2561
+ console.log(JSON.stringify({
2562
+ success: false,
2563
+ mode: 'agent',
2564
+ model: this.currentModel,
2565
+ partial: false,
2566
+ content: '',
2567
+ error: formatted,
2568
+ errorCategory: cliErr.category,
2569
+ metadata: { executionPath: 'local-agent-loop' },
2570
+ }, null, 2));
2571
+ }
2572
+ return false;
2573
+ }
2574
+ if (this.jsonOutput) {
2575
+ process.exitCode = 1;
2576
+ console.log(JSON.stringify({
2577
+ success: false,
2578
+ mode: 'agent',
2579
+ model: this.currentModel,
2580
+ partial: false,
2581
+ content: '',
2582
+ error: formatted,
2583
+ errorCategory: cliErr.category,
2584
+ metadata: { executionPath: 'local-agent-loop' },
2585
+ }, null, 2));
2586
+ }
2587
+ else {
2588
+ this.logger.error(formatted);
2589
+ }
2590
+ return true;
1657
2591
  }
1658
2592
  }
1659
2593
  if (spinner)
@@ -1663,6 +2597,11 @@ export class ChatCommand {
1663
2597
  this.messages.push({ role: 'assistant', content: assistantMessage });
1664
2598
  const toolCalls = this.extractToolCalls(assistantMessage);
1665
2599
  const visibleText = this.stripToolPayloads(assistantMessage).trim();
2600
+ if (visibleText && !this.jsonOutput) {
2601
+ this.localAgentIterationCount += 1;
2602
+ console.log(chalk.cyan(`\n── Iteration ${this.localAgentIterationCount} ──`));
2603
+ console.log(chalk.gray(visibleText));
2604
+ }
1666
2605
  getBridgeClient()?.emitModelResponse({
1667
2606
  model: this.currentModel,
1668
2607
  chars: assistantMessage.length,
@@ -1681,18 +2620,25 @@ export class ChatCommand {
1681
2620
  // Detect resignation: model gives up saying files/things were "not found"
1682
2621
  // without having tried list_dir to discover the correct path.
1683
2622
  const isResignation = /(?:not found|cannot be (?:determined|compared|completed)|do not exist|does not exist|unable to locate|neither.*exist|could not (?:find|locate)|no (?:such|matching) file)/i.test(sanitized) && this.agentToolEvidence.discovery < 4;
2623
+ const actionablePrompt = this.lastActionableUserInput || prompt;
2624
+ const needsRepoWork = this.isDiagnosticPrompt(actionablePrompt) || this.isImplementationPrompt(actionablePrompt);
1684
2625
  // Gate 1: First turn with no discovery at all
1685
- const gate1 = turn === 0 && this.agentToolEvidence.discovery === 0 && (this.isDiagnosticPrompt(prompt) || this.directPromptMode || isPolicyAck);
2626
+ const gate1 = turn === 0 && this.agentToolEvidence.discovery === 0 && (needsRepoWork || this.directPromptMode || this.agentMode || isPolicyAck);
1686
2627
  // Gate 2: Any turn where the response is just a follow-up question,
1687
2628
  // tool-failure echoes, or premature resignation (the model gave up
1688
2629
  // instead of retrying with list_dir to find the correct paths)
1689
- const gate2 = this.directPromptMode && turn < 6 && (isPolicyAck || isFollowUp || isEmptyAfterSanitize || isResignation);
2630
+ const gate2 = (this.directPromptMode || this.agentMode) && turn < 6 && (isPolicyAck || isFollowUp || isEmptyAfterSanitize || isResignation);
2631
+ const gateNoToolsNoWork = needsRepoWork
2632
+ && this.agentToolEvidence.discovery === 0
2633
+ && this.agentToolEvidence.mutation === 0
2634
+ && turn < maxTurns - 1
2635
+ && (isEmptyAfterSanitize || isPolicyAck || isFollowUp);
1690
2636
  // Gate 3: Model outputs code blocks as text instead of using write_file.
1691
2637
  // If the response contains ``` code fences but no write_file was called,
1692
2638
  // reject and instruct the model to use write_file.
1693
2639
  const hasCodeBlocks = (sanitized.match(/```/g) || []).length >= 2;
1694
2640
  const gate3 = hasCodeBlocks && this.agentToolEvidence.mutation === 0 && turn < 6;
1695
- if (gate1 || gate2 || gate3) {
2641
+ if (gate1 || gate2 || gate3 || gateNoToolsNoWork) {
1696
2642
  // Remove the useless response from history
1697
2643
  if (isPolicyAck || isFollowUp || isEmptyAfterSanitize || isResignation || gate3) {
1698
2644
  this.messages.pop();
@@ -1731,7 +2677,7 @@ export class ChatCommand {
1731
2677
  console.log(finalContent);
1732
2678
  }
1733
2679
  this.saveSession();
1734
- return;
2680
+ return true;
1735
2681
  }
1736
2682
  await this.executeToolCalls(toolCalls);
1737
2683
  this.directToolContinuationCount += 1;
@@ -1765,7 +2711,7 @@ export class ChatCommand {
1765
2711
  else {
1766
2712
  this.logger.error(errorMsg);
1767
2713
  }
1768
- return;
2714
+ return true;
1769
2715
  }
1770
2716
  }
1771
2717
  if (this.jsonOutput) {
@@ -1786,6 +2732,66 @@ export class ChatCommand {
1786
2732
  console.log('Task complete.');
1787
2733
  }
1788
2734
  this.saveSession();
2735
+ return true;
2736
+ }
2737
+ async primeAgentWorkspaceDiscovery(prompt) {
2738
+ if (!this.tools || this.agentToolEvidence.discovery > 0) {
2739
+ return;
2740
+ }
2741
+ const actionablePrompt = this.buildContextualAgentPrompt(prompt);
2742
+ if (!this.isDiagnosticPrompt(actionablePrompt) && !this.isImplementationPrompt(actionablePrompt)) {
2743
+ return;
2744
+ }
2745
+ const listCall = {
2746
+ tool: 'list_dir',
2747
+ args: { path: '.' },
2748
+ };
2749
+ if (!this.jsonOutput) {
2750
+ console.log(chalk.cyan('⚙ Executing: list_dir → workspace root (agent bootstrap)'));
2751
+ }
2752
+ const result = await this.tools.execute(listCall);
2753
+ const summary = this.formatToolResult(listCall, result);
2754
+ if (!this.jsonOutput) {
2755
+ console.log(result.success ? chalk.gray(summary) : chalk.red(summary));
2756
+ }
2757
+ this.messages.push({ role: 'system', content: summary });
2758
+ getBridgeClient()?.emitToolResult({
2759
+ tool: listCall.tool,
2760
+ success: result.success,
2761
+ preview: (result.output || result.error || '').slice(0, 300),
2762
+ });
2763
+ if (result.success) {
2764
+ this.agentToolEvidence.discovery += 1;
2765
+ }
2766
+ }
2767
+ async primeBypassedTargetFileContext(prompt) {
2768
+ if (!this.directPromptMode || !this.tools) {
2769
+ return;
2770
+ }
2771
+ const targetFile = this.inferTargetFileFromPrompt(prompt);
2772
+ if (!targetFile) {
2773
+ return;
2774
+ }
2775
+ // Only prime if the direct-file flow was bypassed for this file (e.g. HTML routed to V3).
2776
+ // This ensures the local agent loop always has full file awareness before planning.
2777
+ if (!this.shouldBypassDirectSingleFileFlow(targetFile, prompt)) {
2778
+ return;
2779
+ }
2780
+ const readCall = {
2781
+ tool: 'read_file',
2782
+ args: { path: targetFile },
2783
+ };
2784
+ if (!this.jsonOutput) {
2785
+ console.log(chalk.cyan(`⚙ Executing: ${readCall.tool}`));
2786
+ }
2787
+ const readResult = await this.tools.execute(readCall);
2788
+ const readSummary = this.formatToolResult(readCall, readResult);
2789
+ if (!this.jsonOutput) {
2790
+ console.log(readResult.success ? chalk.gray(readSummary) : chalk.red(readSummary));
2791
+ }
2792
+ if (readResult.success && readResult.output) {
2793
+ this.messages.push({ role: 'system', content: readSummary });
2794
+ }
1789
2795
  }
1790
2796
  async tryDirectSingleFileFlow(prompt) {
1791
2797
  if (!this.directPromptMode || !this.tools) {
@@ -1903,7 +2909,7 @@ export class ChatCommand {
1903
2909
  }
1904
2910
  isConfirmationFollowUp(prompt) {
1905
2911
  const normalized = prompt.trim().toLowerCase().replace(/[.!?]+$/g, '').replace(/\s+/g, ' ');
1906
- return /^(ja|ja bitte|ja bitte mach das|mach das|bitte mach das|genau|ok|okay|yes|yes please|please do|do it|go ahead|continue|proceed|make it so)$/.test(normalized);
2912
+ return /^(ja|ja bitte|ja bitte mach das|mach das|bitte mach das|genau|ok|okay|yes|yes please|please do|do it|go ahead|go on|continue|proceed|make it so|keep going|next)$/.test(normalized);
1907
2913
  }
1908
2914
  getPreviousActionablePrompt() {
1909
2915
  if (this.lastActionableUserInput && !this.isConfirmationFollowUp(this.lastActionableUserInput)) {
@@ -1942,7 +2948,16 @@ export class ChatCommand {
1942
2948
  'Do not reinterpret this confirmation as a new website, landing page, template, or index.html task.',
1943
2949
  ].join('\n');
1944
2950
  }
1945
- async tryV3AgentWorkflow(prompt) {
2951
+ async tryV3AgentWorkflow(prompt, options = {}) {
2952
+ // Extract explicit workspace path from prompt (if provided by user)
2953
+ let promptWorkspacePath = null;
2954
+ try {
2955
+ promptWorkspacePath = this.resolvePromptWorkspacePath(prompt, this.currentProjectPath);
2956
+ }
2957
+ catch {
2958
+ // Path extraction failed, use default workspace
2959
+ }
2960
+ const workspacePath = promptWorkspacePath || this.currentProjectPath;
1946
2961
  const contextualPrompt = this.buildContextualAgentPrompt(prompt);
1947
2962
  if (contextualPrompt === prompt && !this.isConfirmationFollowUp(prompt)) {
1948
2963
  this.lastActionableUserInput = prompt;
@@ -1950,11 +2965,28 @@ export class ChatCommand {
1950
2965
  this.messages.push({ role: 'user', content: contextualPrompt });
1951
2966
  const runtimeContext = await this.getPromptRuntimeContext(contextualPrompt);
1952
2967
  const routingPolicy = this.resolveAgentExecutionPolicy(contextualPrompt);
2968
+ // STREAMING: Log routing decision transparently to user
2969
+ if (!this.jsonOutput) {
2970
+ console.log();
2971
+ console.log(chalk.gray('━━━ ROUTING DECISION ━━━'));
2972
+ console.log(chalk.gray(`Reason: ${routingPolicy.routeReason}`));
2973
+ console.log(chalk.gray(`Model: ${routingPolicy.selectedModel}`));
2974
+ console.log(chalk.gray(`Cloud Eligible: ${routingPolicy.cloudEligible}`));
2975
+ console.log(chalk.gray(`Cloud Selected: ${routingPolicy.cloudSelected}`));
2976
+ if (routingPolicy.heavyTask) {
2977
+ console.log(chalk.gray(`Task Complexity: HEAVY`));
2978
+ }
2979
+ console.log(chalk.gray('━'.repeat(30)));
2980
+ console.log();
2981
+ }
1953
2982
  // Reset streaming counters for new workflow
1954
2983
  this.v3IterationCount = 0;
1955
2984
  this.v3ToolCallCount = 0;
1956
2985
  this.v3LastActivity = Date.now();
1957
2986
  this.v3StreamingStarted = false;
2987
+ this.v3StreamedTextBuffer = '';
2988
+ this.v3LiveToolEvidence = [];
2989
+ this.v3PendingToolCalls = [];
1958
2990
  const taskDisplay = new TaskDisplay(['Analyse workspace', 'Execute tasks', 'Validate output', 'Self-heal'], !this.jsonOutput);
1959
2991
  taskDisplay.start(0);
1960
2992
  const spinner = this.jsonOutput ? null : createSpinner({
@@ -2018,25 +3050,59 @@ export class ChatCommand {
2018
3050
  const executionPrompt = this.buildExecutionPrompt(contextualPrompt);
2019
3051
  const agentTaskType = this.inferAgentTaskType(contextualPrompt);
2020
3052
  const workspaceContext = {
2021
- workspacePath: this.currentProjectPath,
2022
- projectPath: this.currentProjectPath,
2023
- targetPath: this.currentProjectPath,
3053
+ workspacePath: workspacePath,
3054
+ projectPath: workspacePath,
3055
+ targetPath: workspacePath,
2024
3056
  ...runtimeContext,
2025
3057
  };
3058
+ if (!this.jsonOutput && !this.directPromptMode) {
3059
+ try {
3060
+ const snapshot = this.api.getAgentWorkspaceSnapshot(workspacePath);
3061
+ console.log(chalk.gray(`Workspace sync: ${snapshot.fileCount} files indexed from ${workspacePath}`));
3062
+ if (snapshot.paths.length > 0) {
3063
+ console.log(chalk.gray(`Workspace index: ${snapshot.paths.slice(0, 5).map((filePath) => filePath.replace(/\\/g, '/')).join(', ')}${snapshot.paths.length > 5 ? ', ...' : ''}`));
3064
+ }
3065
+ }
3066
+ catch {
3067
+ console.log(chalk.gray(`Workspace sync: preparing ${workspacePath}`));
3068
+ }
3069
+ }
2026
3070
  // Start workspace watcher for bidirectional real-time sync
2027
3071
  let watcher = null;
2028
- if (this.currentProjectPath && fs.existsSync(this.currentProjectPath)) {
3072
+ let workspaceWs = null;
3073
+ const stopWorkspaceSync = () => {
3074
+ watcher?.stop();
3075
+ if (workspaceWs) {
3076
+ workspaceWs.disconnect();
3077
+ workspaceWs = null;
3078
+ }
3079
+ };
3080
+ if (this.shouldStartWorkspaceWatcher(workspacePath)) {
2029
3081
  watcher = new WorkspaceWatcher({
2030
- workspaceRoot: this.currentProjectPath,
3082
+ workspaceRoot: workspacePath,
2031
3083
  onFileChange: (relativePath, content, action) => {
2032
3084
  this.logger.debug(`Local change detected: ${action} ${relativePath}`);
3085
+ workspaceWs?.syncFile(relativePath, content, action);
2033
3086
  },
2034
3087
  });
2035
3088
  watcher.start();
2036
3089
  }
2037
3090
  try {
3091
+ // Ensure the V3 service key is available before health check
3092
+ await this.api.ensureV3ServiceKey();
3093
+ if (spinner)
3094
+ this.updateV3AgentSpinner(spinner, { type: 'v3_status', content: 'Checking V3 connection (preflight)...' });
3095
+ const healthCheck = await this.api.runV3HealthCheck({ soft: true });
3096
+ if (!healthCheck.healthy) {
3097
+ this.logger.warn('V3 health probe did not confirm readiness; starting SSE agent stream anyway.');
3098
+ if (spinner)
3099
+ this.updateV3AgentSpinner(spinner, { type: 'v3_status', content: 'Starting V3 agent stream...' });
3100
+ }
3101
+ else if (spinner) {
3102
+ this.updateV3AgentSpinner(spinner, { type: 'v3_status', content: 'Connected to V3. Starting agent execution...' });
3103
+ }
2038
3104
  const workflowPromise = this.api.runV3AgentWorkflow(executionPrompt, {
2039
- workspace: { path: this.currentProjectPath },
3105
+ workspace: { path: workspacePath },
2040
3106
  ...workspaceContext,
2041
3107
  agentTaskType,
2042
3108
  executionSurface: 'cli',
@@ -2052,6 +3118,32 @@ export class ChatCommand {
2052
3118
  rawPrompt: prompt,
2053
3119
  contextualPrompt,
2054
3120
  history: this.getMessagesForModel(),
3121
+ clientToolExecution: true,
3122
+ liveToolEvidence: this.v3LiveToolEvidence,
3123
+ onClientToolExecute: (event) => this.executeClientV3Tool(event),
3124
+ onWorkspaceContext: async ({ contextId, serverWorkspaceRoot }) => {
3125
+ if (workspaceWs || !contextId || !serverWorkspaceRoot || !this.shouldStartWorkspaceWatcher(workspacePath)) {
3126
+ return;
3127
+ }
3128
+ const headers = await this.api.getV3AgentHeaders();
3129
+ const token = String(headers.Authorization?.replace(/^Bearer\s+/i, '')
3130
+ || headers['X-V3-Service-Key']
3131
+ || headers['x-v3-service-key']
3132
+ || '').trim();
3133
+ if (!token) {
3134
+ return;
3135
+ }
3136
+ const baseUrl = this.api.getV3AgentBaseUrls(false)[0] || 'https://coder.vigthoria.io';
3137
+ const serverUrl = baseUrl.replace(/^http/i, 'ws');
3138
+ workspaceWs = new WorkspaceWSClient({
3139
+ serverUrl,
3140
+ token,
3141
+ contextId,
3142
+ workspaceRoot: serverWorkspaceRoot,
3143
+ });
3144
+ workspaceWs.connect();
3145
+ this.logger.debug(`Workspace WS sync bound to context ${contextId}`);
3146
+ },
2055
3147
  ...runtimeContext,
2056
3148
  onStreamEvent: (event) => {
2057
3149
  if (event.type === 'plan') {
@@ -2111,6 +3203,34 @@ export class ChatCommand {
2111
3203
  if (this.v3StreamingStarted) {
2112
3204
  process.stdout.write('\n');
2113
3205
  }
3206
+ let finalContent = String(response.content || '').trim();
3207
+ const needsUserReport = this.inferAgentTaskType(prompt) === 'analysis'
3208
+ || this.isThinV3Summary(finalContent)
3209
+ || this.v3LiveToolEvidence.length > 0;
3210
+ if (needsUserReport) {
3211
+ const userReport = this.buildUserFacingV3RunReport(prompt, workspacePath, {
3212
+ partial: response.partial === true || this.isThinV3Summary(finalContent),
3213
+ serverNote: finalContent && !this.isThinV3Summary(finalContent) ? finalContent : undefined,
3214
+ });
3215
+ if (userReport && !this.isThinV3Summary(userReport)) {
3216
+ finalContent = userReport;
3217
+ }
3218
+ }
3219
+ else if (this.isThinV3Summary(finalContent) && this.v3LiveToolEvidence.length > 0) {
3220
+ const evidenceSummary = this.api.formatV3AgentResponse({
3221
+ events: [],
3222
+ liveToolEvidence: this.v3LiveToolEvidence,
3223
+ });
3224
+ if (evidenceSummary && !this.isThinV3Summary(evidenceSummary)) {
3225
+ finalContent = evidenceSummary;
3226
+ }
3227
+ }
3228
+ response.content = finalContent || response.content;
3229
+ let v3UserReportPrinted = false;
3230
+ if (!this.jsonOutput && finalContent && needsUserReport) {
3231
+ this.printV3UserReport(finalContent);
3232
+ v3UserReportPrinted = true;
3233
+ }
2114
3234
  const previewGate = (response.metadata?.previewGate || null);
2115
3235
  const workspaceHasOutput = this.api.hasAgentWorkspaceOutput(workspaceContext);
2116
3236
  const success = previewGate?.required === true
@@ -2123,7 +3243,7 @@ export class ChatCommand {
2123
3243
  }
2124
3244
  this.logger.warn('Falling back to legacy CLI agent loop');
2125
3245
  this.logger.debug(`V3 agent workflow returned an incomplete result: ${previewGate?.error || 'workspace changes were not fully validated'}`);
2126
- watcher?.stop();
3246
+ stopWorkspaceSync();
2127
3247
  return false;
2128
3248
  }
2129
3249
  const errorMessage = `V3 agent workflow returned an incomplete result and legacy fallback is disabled. ${previewGate?.error || 'Workspace changes were not fully validated.'}`;
@@ -2144,7 +3264,7 @@ export class ChatCommand {
2144
3264
  metadata: { executionPath: 'v3-agent', previewGate },
2145
3265
  }, null, 2));
2146
3266
  }
2147
- watcher?.stop();
3267
+ stopWorkspaceSync();
2148
3268
  return true;
2149
3269
  }
2150
3270
  if (!this.jsonOutput && previewGate?.required && previewGate?.passed !== true && workspaceHasOutput) {
@@ -2164,16 +3284,32 @@ export class ChatCommand {
2164
3284
  }, null, 2));
2165
3285
  }
2166
3286
  else if (this.v3StreamingStarted) {
2167
- // Content was already streamed to stdout in real-time; skip duplicate print.
2168
- if (!this.jsonOutput) {
3287
+ // Content may have been streamed already, but some V3 runs only stream
3288
+ // status/tool messages and keep the useful final report in response.content.
3289
+ if (!v3UserReportPrinted && !this.jsonOutput) {
2169
3290
  console.log(chalk.gray(`Agent routing: ${routingPolicy.cloudSelected ? 'Vigthoria Cloud' : 'V3 Agent'}`));
2170
3291
  }
3292
+ if (!v3UserReportPrinted && response.content && this.shouldPrintV3FinalContent(response.content)) {
3293
+ console.log(response.content);
3294
+ }
3295
+ else if (!v3UserReportPrinted && response.content && this.isThinV3Summary(response.content)) {
3296
+ console.log(chalk.yellow('V3 agent finished after reading local files, but no final narrative summary was emitted.'));
3297
+ console.log(chalk.gray('Use /continue and ask for a written overview.'));
3298
+ }
2171
3299
  }
2172
3300
  else if (response.content) {
2173
- if (!this.directPromptMode) {
3301
+ if (!v3UserReportPrinted && !this.directPromptMode) {
2174
3302
  console.log(chalk.gray(`Agent routing: ${routingPolicy.cloudSelected ? 'Vigthoria Cloud' : 'V3 Agent'}`));
2175
3303
  }
2176
- console.log(response.content);
3304
+ if (!v3UserReportPrinted && this.shouldPrintV3FinalContent(response.content)) {
3305
+ console.log(response.content);
3306
+ }
3307
+ else if (!v3UserReportPrinted && this.isThinV3Summary(response.content)) {
3308
+ console.log('V3 agent workflow completed, but no final task summary was emitted.');
3309
+ }
3310
+ else if (!v3UserReportPrinted) {
3311
+ console.log(response.content);
3312
+ }
2177
3313
  }
2178
3314
  else {
2179
3315
  if (!this.directPromptMode) {
@@ -2285,11 +3421,11 @@ export class ChatCommand {
2285
3421
  this.printAgentRunSummary(this.lastAgentRunOutcome, executorSucceeded, changedFileCount);
2286
3422
  }
2287
3423
  this.messages.push({ role: 'assistant', content: response.content || 'V3 agent workflow completed.' });
2288
- watcher?.stop();
3424
+ stopWorkspaceSync();
2289
3425
  return true;
2290
3426
  }
2291
3427
  catch (error) {
2292
- watcher?.stop();
3428
+ stopWorkspaceSync();
2293
3429
  if (!this.api.hasAgentWorkspaceOutput(workspaceContext)) {
2294
3430
  const recovered = await this.tryRecoverV3ServiceAndRetry(executionPrompt, prompt, workspaceContext, routingPolicy, spinner, error);
2295
3431
  if (recovered) {
@@ -2491,6 +3627,14 @@ export class ChatCommand {
2491
3627
  this.showProjectMemory();
2492
3628
  continue;
2493
3629
  }
3630
+ if (trimmed === '/index') {
3631
+ await this.reindexWorkspaceBrain();
3632
+ continue;
3633
+ }
3634
+ if (trimmed === '/brain') {
3635
+ this.showBrainIndexStatus();
3636
+ continue;
3637
+ }
2494
3638
  if (trimmed === '/compact') {
2495
3639
  this.compactCurrentSession();
2496
3640
  continue;
@@ -2592,9 +3736,15 @@ export class ChatCommand {
2592
3736
  this.syncInteractiveModeModel('agent');
2593
3737
  console.log(chalk.gray('Agent mode re-enabled for continuation.'));
2594
3738
  }
2595
- await this.runAgentTurn(followUp);
3739
+ await this.runAgentTurn(followUp, { skipLocalLoop: true, forceV3: true });
2596
3740
  continue;
2597
3741
  }
3742
+ if (/^(?:\/logout|logout)$/i.test(trimmed)) {
3743
+ const { logout } = await import('./auth.js');
3744
+ await logout();
3745
+ console.log(chalk.gray('Session ended.'));
3746
+ break;
3747
+ }
2598
3748
  if (trimmed === '/save') {
2599
3749
  this.saveSession();
2600
3750
  console.log(chalk.green('Session saved.'));
@@ -2633,6 +3783,8 @@ export class ChatCommand {
2633
3783
  console.log(' /operator Toggle BMAD operator mode');
2634
3784
  console.log(' /context Show current session and project memory');
2635
3785
  console.log(' /memory Show Vigthoria project brain status');
3786
+ console.log(' /brain Show workspace codebase index status');
3787
+ console.log(' /index Re-index workspace and sync to Brain Hub');
2636
3788
  console.log(' /compact Compact current session into memory summary');
2637
3789
  console.log(' /clear Clear conversation');
2638
3790
  console.log(' /save Save session');
@@ -2681,6 +3833,9 @@ export class ChatCommand {
2681
3833
  console.log(chalk.gray(' Pending: ') + chalk.yellow(unfinishedList.join(', ')) + more);
2682
3834
  }
2683
3835
  }
3836
+ else if (!executorSucceeded) {
3837
+ console.log(chalk.gray(' The detailed overview is printed above when available.'));
3838
+ }
2684
3839
  if (typeof outcome.qualityScore === 'number') {
2685
3840
  const score = outcome.qualityScore.toFixed(1);
2686
3841
  const colour = outcome.qualityScore >= 70 ? chalk.green : outcome.qualityScore >= 30 ? chalk.yellow : chalk.red;
@@ -2769,7 +3924,10 @@ export class ChatCommand {
2769
3924
  const missingLine = o.qualityMissing.length > 0
2770
3925
  ? `\nMissing pieces: ${o.qualityMissing.slice(0, 6).join(', ')}.`
2771
3926
  : '';
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}`;
3927
+ return `Continue the previous agent run from the current workspace state without re-doing already-completed work.${taskList}${blockerLine}${missingLine}
3928
+ Original request was: ${o.prompt}
3929
+
3930
+ 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
3931
  }
2774
3932
  /**
2775
3933
  * Re-print the last agent run summary, or guide the user when there isn't one.
@@ -2857,6 +4015,7 @@ export class ChatCommand {
2857
4015
  });
2858
4016
  }
2859
4017
  buildAgentSystemPrompt() {
4018
+ const runtime = this.getRuntimeEnvironmentContext();
2860
4019
  const toolCatalog = AgenticTools.getToolDefinitions()
2861
4020
  .map((tool) => {
2862
4021
  const params = tool.parameters
@@ -2868,6 +4027,7 @@ export class ChatCommand {
2868
4027
  return [
2869
4028
  'Vigthoria CLI agent operating contract.',
2870
4029
  `You are operating inside the project root: ${this.currentProjectPath}`,
4030
+ `Execution platform: ${runtime.platform} (${runtime.osPlatform}). Machine scope: ${runtime.machineScope}.`,
2871
4031
  `You are operating on the user's LOCAL machine. This CLI is not a server-only runtime.`,
2872
4032
  `All file reads/writes and command execution must target the local project/workspace path unless the user explicitly requests remote/server execution.`,
2873
4033
  'For command execution: ask for confirmation before risky commands; never redirect normal local work to server paths.',
@@ -2900,10 +4060,12 @@ export class ChatCommand {
2900
4060
  ].join('\n');
2901
4061
  }
2902
4062
  buildScopedUserPrompt(prompt) {
4063
+ const runtime = this.getRuntimeEnvironmentContext();
2903
4064
  return [
2904
4065
  this.buildExecutionPrompt(prompt),
2905
4066
  '',
2906
4067
  `Project root: ${this.currentProjectPath}`,
4068
+ `Execution platform: ${runtime.platform}. Machine scope: ${runtime.machineScope}.`,
2907
4069
  'Stay within this project root unless the user explicitly expands scope.',
2908
4070
  'Finish the request and stop once it is complete.',
2909
4071
  ].join('\n');
@@ -2965,7 +4127,14 @@ export class ChatCommand {
2965
4127
  return true;
2966
4128
  }
2967
4129
  const extension = path.extname(targetFile).toLowerCase();
2968
- return extension === '';
4130
+ if (extension === '')
4131
+ return true;
4132
+ // HTML/HTM files need V3 agent with the 35B model for quality rewrites (canvas,
4133
+ // styling, interactivity). Route them to tryV3AgentWorkflow instead of the
4134
+ // lightweight cloud-chat direct-file path.
4135
+ if (extension === '.html' || extension === '.htm')
4136
+ return true;
4137
+ return false;
2969
4138
  }
2970
4139
  shouldPreferLocalAgentLoop(prompt) {
2971
4140
  if (this.shouldRequireV3AgentWorkflow(prompt)) {
@@ -2975,16 +4144,44 @@ export class ChatCommand {
2975
4144
  if (forceV3) {
2976
4145
  return false;
2977
4146
  }
4147
+ if (this.isBrowserTaskPrompt(prompt)) {
4148
+ return false;
4149
+ }
2978
4150
  const runtime = this.getRuntimeEnvironmentContext();
2979
- // Local machines and non-server-bindable workspaces should run locally first.
4151
+ const forceRemoteAgent = /^(1|true|yes)$/i.test(String(process.env.VIGTHORIA_PREFER_V3_AGENT_LOOP
4152
+ || process.env.VIGTHORIA_FORCE_REMOTE_AGENT
4153
+ || ''));
4154
+ // CLI sessions on a user's local machine must execute tools locally.
4155
+ // Remote V3 only sees a partial hydrated copy and cannot reach real paths.
4156
+ if (runtime.machineScope === 'local-machine') {
4157
+ if (forceRemoteAgent) {
4158
+ return false;
4159
+ }
4160
+ if (this.isImplementationPrompt(prompt)) {
4161
+ return true;
4162
+ }
4163
+ const preferLocalOptIn = /^(1|true|yes)$/i.test(String(process.env.VIGTHORIA_PREFER_LOCAL_AGENT_LOOP || ''));
4164
+ // Read-only analysis on a Windows/macOS/Linux desktop is faster and more
4165
+ // reliable through V3 + client-side read tools than a blocking chat route.
4166
+ if (this.isAnalysisLookupPrompt(prompt) && !preferLocalOptIn) {
4167
+ return false;
4168
+ }
4169
+ return true;
4170
+ }
4171
+ // Server-bindable workspaces keep V3 as the default execution path.
4172
+ const preferLocalOptIn = /^(1|true|yes)$/i.test(String(process.env.VIGTHORIA_PREFER_LOCAL_AGENT_LOOP || ''));
4173
+ if (!preferLocalOptIn) {
4174
+ return false;
4175
+ }
4176
+ if (this.directPromptMode && this.isRepoGroundedPrompt(prompt)) {
4177
+ return false;
4178
+ }
2980
4179
  if (!runtime.serverBindableWorkspace) {
2981
4180
  return true;
2982
4181
  }
2983
- // Interactive sessions should prioritize local edits unless V3 is explicitly forced.
2984
4182
  if (!this.directPromptMode) {
2985
4183
  return true;
2986
4184
  }
2987
- // For direct prompts on server-bindable Linux roots, keep V3-first behavior.
2988
4185
  return runtime.platform !== 'linux';
2989
4186
  }
2990
4187
  getRuntimeEnvironmentContext() {
@@ -3026,7 +4223,7 @@ export class ChatCommand {
3026
4223
  if (!projectPath || !path.isAbsolute(projectPath) || !fs.existsSync(projectPath)) {
3027
4224
  return false;
3028
4225
  }
3029
- const configuredRoots = (process.env.VIGTHORIA_SERVER_WORKSPACE_ROOTS || '/var/www/vigthoria-user-workspaces,/var/lib/vigthoria-workspaces')
4226
+ const configuredRoots = (process.env.VIGTHORIA_SERVER_WORKSPACE_ROOTS || '/var/www,/var/www/vigthoria-user-workspaces,/var/lib/vigthoria-workspaces')
3030
4227
  .split(',')
3031
4228
  .map((entry) => entry.trim())
3032
4229
  .filter(Boolean);
@@ -3316,6 +4513,15 @@ export class ChatCommand {
3316
4513
  if (fallback) {
3317
4514
  return fallback;
3318
4515
  }
4516
+ if (this.agentToolEvidence.discovery === 0 && this.agentToolEvidence.mutation === 0) {
4517
+ if (this.isImplementationPrompt(prompt) || this.isDiagnosticPrompt(prompt)) {
4518
+ return [
4519
+ 'The agent did not inspect the workspace or run any tools before finishing.',
4520
+ 'This usually means the model backend returned an empty response.',
4521
+ 'Try the request again. If it keeps happening, check Vigthoria Coder / vLLM logs on the server.',
4522
+ ].join(' ');
4523
+ }
4524
+ }
3319
4525
  return sanitized || 'Task complete.';
3320
4526
  }
3321
4527
  /**
@@ -3325,7 +4531,11 @@ export class ChatCommand {
3325
4531
  * substantive answer.
3326
4532
  */
3327
4533
  sanitizeDirectModeOutput(text) {
3328
- let cleaned = text;
4534
+ let cleaned = this.stripHiddenThoughtBlocks(text);
4535
+ cleaned = cleaned
4536
+ .replace(/<\|mask_start\|>[\s\S]*?<\|mask_end\|>/g, '')
4537
+ .replace(/<think>[\s\S]*?<\/think>/gi, '')
4538
+ .replace(/<\|(?:mask_start|mask_end)\|>/g, '');
3329
4539
  // ── Phase 1: Strip entire tool-output blocks ──
3330
4540
  // Matches "Tool <name> succeeded/FAILED." through the next blank line,
3331
4541
  // next tool header, or end-of-string. The DOTALL-like [\s\S]*? is
@@ -3398,7 +4608,7 @@ export class ChatCommand {
3398
4608
  cleaned = kept.join('\n\n');
3399
4609
  // Collapse multiple blank lines
3400
4610
  cleaned = cleaned.replace(/\n{3,}/g, '\n\n').trim();
3401
- return cleaned || text;
4611
+ return cleaned || this.stripHiddenThoughtBlocks(text);
3402
4612
  }
3403
4613
  isDirectModeFollowUpQuestion(text) {
3404
4614
  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 +4690,57 @@ export class ChatCommand {
3480
4690
  .trim();
3481
4691
  return normalized || null;
3482
4692
  }
4693
+ async tryLocalToolFallback(call, result) {
4694
+ if (result.success || !this.tools) {
4695
+ return result;
4696
+ }
4697
+ const runtime = this.getRuntimeEnvironmentContext();
4698
+ if (runtime.machineScope !== 'local-machine') {
4699
+ return result;
4700
+ }
4701
+ if (call.tool === 'read_file' && call.args.path) {
4702
+ const parent = path.dirname(call.args.path);
4703
+ const listPath = parent === '.' || parent === '' ? '.' : parent;
4704
+ const listResult = await this.tools.execute({
4705
+ tool: 'list_dir',
4706
+ args: { path: listPath },
4707
+ });
4708
+ if (listResult.success && listResult.output) {
4709
+ return {
4710
+ ...result,
4711
+ suggestion: `File not found at "${call.args.path}". Parent directory "${listPath}" contains:\n${listResult.output}`,
4712
+ };
4713
+ }
4714
+ const baseName = path.basename(call.args.path);
4715
+ const globResult = await this.tools.execute({
4716
+ tool: 'glob',
4717
+ args: { pattern: `**/${baseName}` },
4718
+ });
4719
+ if (globResult.success && globResult.output?.trim()) {
4720
+ return {
4721
+ ...result,
4722
+ suggestion: `File not found at "${call.args.path}". Matching paths:\n${globResult.output}`,
4723
+ };
4724
+ }
4725
+ }
4726
+ if (call.tool === 'list_dir') {
4727
+ const listPath = call.args.path || '.';
4728
+ const command = runtime.platform === 'windows'
4729
+ ? `dir /b "${path.join(this.currentProjectPath, listPath)}"`
4730
+ : `ls -la "${path.join(this.currentProjectPath, listPath)}"`;
4731
+ const shellResult = await this.tools.execute({
4732
+ tool: 'bash',
4733
+ args: { command, cwd: this.currentProjectPath },
4734
+ });
4735
+ if (shellResult.success && shellResult.output) {
4736
+ return {
4737
+ ...result,
4738
+ suggestion: `list_dir failed for "${listPath}". Terminal listing:\n${shellResult.output}`,
4739
+ };
4740
+ }
4741
+ }
4742
+ return result;
4743
+ }
3483
4744
  async executeToolCalls(toolCalls) {
3484
4745
  if (!this.tools) {
3485
4746
  throw new Error('Agent tools are not initialized.');
@@ -3489,10 +4750,15 @@ export class ChatCommand {
3489
4750
  const verbose = !this.jsonOutput;
3490
4751
  for (const call of toolCalls) {
3491
4752
  if (verbose) {
3492
- console.log(chalk.cyan(`⚙ Executing: ${call.tool}`));
4753
+ const target = call.args.path || call.args.pattern || call.args.command || '';
4754
+ const detail = target ? chalk.gray(` → ${String(target).replace(/\\/g, '/')}`) : '';
4755
+ console.log(chalk.cyan(`⚙ Executing: ${call.tool}`) + detail);
3493
4756
  }
3494
4757
  getBridgeClient()?.emitToolCall({ tool: call.tool, args: call.args });
3495
4758
  let result = await this.tools.execute(call);
4759
+ if (!result.success) {
4760
+ result = await this.tryLocalToolFallback(call, result);
4761
+ }
3496
4762
  // Phase 2: If a search tool failed (search_failed), retry with alternate approach
3497
4763
  const searchStatus = result.metadata?.searchStatus;
3498
4764
  if (call.tool === 'grep' && searchStatus === 'search_failed') {