vigthoria-cli 1.11.37 → 1.11.43

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.
@@ -2,6 +2,12 @@ import chalk from 'chalk';
2
2
  import { createSpinner } from '../utils/logger.js';
3
3
  import { APIClient } from '../utils/api.js';
4
4
  import { getDesktopBridgeStatus } from '../utils/desktop-bridge-client.js';
5
+ function displayEndpoint(endpoint) {
6
+ if (/^(?:wss?|https?):\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?/i.test(endpoint)) {
7
+ return 'local loopback endpoint';
8
+ }
9
+ return endpoint;
10
+ }
5
11
  export class BridgeCommand {
6
12
  api;
7
13
  constructor(config, logger) {
@@ -18,7 +24,7 @@ export class BridgeCommand {
18
24
  console.log(chalk.white('DevTools Bridge (browser, port 4016):'));
19
25
  console.log(chalk.gray(' Status: ') + (devtoolsBridge.ok ? chalk.green('Reachable') : chalk.yellow('Not running')));
20
26
  if (devtoolsBridge.ok) {
21
- console.log(chalk.gray(' Endpoint: ') + devtoolsBridge.endpoint);
27
+ console.log(chalk.gray(' Endpoint: ') + displayEndpoint(devtoolsBridge.endpoint));
22
28
  }
23
29
  else {
24
30
  const detail = String(devtoolsBridge.error || 'Connection refused').trim() || 'Connection refused';
@@ -31,7 +37,7 @@ export class BridgeCommand {
31
37
  console.log(chalk.white('Desktop Bridge (Windows desktop, port 49160):'));
32
38
  console.log(chalk.gray(' Status: ') + (desktopBridge.ok ? chalk.green('Reachable') : chalk.yellow('Not running')));
33
39
  if (desktopBridge.ok) {
34
- console.log(chalk.gray(' Endpoint: ') + desktopBridge.endpoint);
40
+ console.log(chalk.gray(' Endpoint: ') + displayEndpoint(desktopBridge.endpoint));
35
41
  if (desktopBridge.service)
36
42
  console.log(chalk.gray(' Service: ') + desktopBridge.service);
37
43
  if (desktopBridge.version)
@@ -68,6 +68,7 @@ export declare class ChatCommand {
68
68
  private hasOperatorAccess;
69
69
  private operatorAccessMessage;
70
70
  private getDefaultChatModel;
71
+ private isHardExplicitAgentModelSelection;
71
72
  private resolveInitialModel;
72
73
  private isLegacyAgentFallbackAllowed;
73
74
  private shouldRescueFailedAnalysis;
@@ -22,27 +22,54 @@ import { inferAgentTaskType as sharedInferAgentTaskType, inferAgentTaskTypeWithC
22
22
  import { resolveAgentRoute } from '../utils/agentRoute.js';
23
23
  import { runTemplateInstantPath } from '../utils/templateInstantPath.js';
24
24
  import { isV3StreamKeepaliveEvent } from '../utils/v3-stream-events.js';
25
+ // Production safety net: previously these defaulted to 0 (disabled) unless an
26
+ // operator manually set an env var. Real end-user installs never set these,
27
+ // so a stalled/incomplete model stream (e.g. text ends after </think> with no
28
+ // tool_call event) would hang the CLI forever with only a cosmetic
29
+ // "[Wait] Model still working" notice and no actual abort. See TestFarm
30
+ // incident 2026-07-04 (Gideon Lenz / C:\vigthoria\Apps\monopoly).
31
+ // Set VIGTHORIA_AGENT_TIMEOUT_MS=0 / VIGTHORIA_AGENT_IDLE_TIMEOUT_MS=0 to
32
+ // explicitly restore the old unbounded behavior for debugging.
25
33
  const DEFAULT_V3_AGENT_TIMEOUT_MS = (() => {
26
34
  const rawValue = process.env.VIGTHORIA_AGENT_TIMEOUT_MS || process.env.V3_AGENT_TIMEOUT_MS;
27
35
  if (!rawValue) {
28
- return 0;
36
+ return 1_200_000; // 20 min hard cap on the whole agent request
29
37
  }
30
38
  const parsed = Number.parseInt(rawValue, 10);
31
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
39
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 1_200_000;
32
40
  })();
33
41
  const DEFAULT_V3_AGENT_IDLE_TIMEOUT_MS = (() => {
34
42
  const rawValue = process.env.VIGTHORIA_AGENT_IDLE_TIMEOUT_MS || process.env.V3_AGENT_IDLE_TIMEOUT_MS;
35
43
  if (!rawValue) {
36
- return 0;
44
+ return 60_000; // 60s with zero new stream data => abort/retry
37
45
  }
38
46
  const parsed = Number.parseInt(rawValue, 10);
39
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
47
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 60_000;
40
48
  })();
41
49
  const DEFAULT_V3_AGENT_SOFT_TIMEOUT_MS = (() => {
42
50
  const rawValue = process.env.VIGTHORIA_AGENT_SOFT_TIMEOUT_MS || process.env.V3_AGENT_SOFT_TIMEOUT_MS || '300000';
43
51
  const parsed = Number.parseInt(rawValue, 10);
44
52
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 300000;
45
53
  })();
54
+ /** Block-write agent JSON to VIGTHORIA_AGENT_OUTPUT (Windows KVM fsync path). */
55
+ function emitAgentJsonOutput(payload) {
56
+ const text = `${JSON.stringify(payload, null, 2)}\n`;
57
+ console.log(text.trimEnd());
58
+ const outPath = process.env.VIGTHORIA_AGENT_OUTPUT?.trim();
59
+ if (!outPath) {
60
+ return;
61
+ }
62
+ const dir = path.dirname(outPath);
63
+ fs.mkdirSync(dir, { recursive: true });
64
+ const fd = fs.openSync(outPath, 'w');
65
+ try {
66
+ fs.writeSync(fd, text, undefined, 'utf8');
67
+ fs.fsyncSync(fd);
68
+ }
69
+ finally {
70
+ fs.closeSync(fd);
71
+ }
72
+ }
46
73
  export class ChatCommand {
47
74
  config;
48
75
  logger;
@@ -251,6 +278,19 @@ export class ChatCommand {
251
278
  }
252
279
  return preferredModel;
253
280
  }
281
+ isHardExplicitAgentModelSelection(options) {
282
+ const requestedModel = String(options.model || '').trim().toLowerCase();
283
+ if (!requestedModel) {
284
+ return false;
285
+ }
286
+ if (/^(1|true|yes)$/i.test(String(process.env.VIGTHORIA_FORCE_EXPLICIT_AGENT_MODEL || ''))) {
287
+ return true;
288
+ }
289
+ if (options.agent === true || options.operator === true) {
290
+ return !['agent', 'code', 'balanced', 'vigthoria-agent', 'vigthoria-code'].includes(requestedModel);
291
+ }
292
+ return true;
293
+ }
254
294
  resolveInitialModel(options) {
255
295
  const requestedModel = String(options.model || '').trim();
256
296
  if (requestedModel) {
@@ -312,6 +352,16 @@ export class ChatCommand {
312
352
  routeReason: 'explicit-model-selection',
313
353
  };
314
354
  }
355
+ if (this.agentMode && ['agent', 'code', 'balanced', 'vigthoria-agent', 'vigthoria-code'].includes(String(this.currentModel || '').toLowerCase())) {
356
+ return {
357
+ selectedModel: 'agent',
358
+ explicitModel: false,
359
+ heavyTask,
360
+ cloudEligible,
361
+ cloudSelected: false,
362
+ routeReason: 'dispatcher-eligible-agent-default',
363
+ };
364
+ }
315
365
  if (this.lastAgentRoute?.path === 'template-instant') {
316
366
  return {
317
367
  selectedModel: 'agent',
@@ -816,7 +866,12 @@ export class ChatCommand {
816
866
  }
817
867
  this.v3IdleWatchInterval = setInterval(() => {
818
868
  const idleMs = Date.now() - this.v3LastActivity;
819
- if (idleMs < 15_000 || this.v3IdleNoticeShown) {
869
+ if (idleMs < 15_000) {
870
+ // Activity resumed — re-arm the notice so a later stall is reported too.
871
+ this.v3IdleNoticeShown = false;
872
+ return;
873
+ }
874
+ if (this.v3IdleNoticeShown) {
820
875
  return;
821
876
  }
822
877
  this.v3IdleNoticeShown = true;
@@ -824,7 +879,11 @@ export class ChatCommand {
824
879
  spinner.stop();
825
880
  }
826
881
  const seconds = Math.round(idleMs / 1000);
827
- process.stderr.write(chalk.yellow(` [Wait] Model still working (${seconds}s) large context or server retry may take up to 90s...\n`));
882
+ const idleTimeoutSec = Math.round(DEFAULT_V3_AGENT_IDLE_TIMEOUT_MS / 1000);
883
+ process.stderr.write(chalk.yellow(` [Wait] Model still working (${seconds}s) — large context or server retry may take a while. ` +
884
+ (idleTimeoutSec > 0
885
+ ? `A hard timeout (~${idleTimeoutSec}s of silence) will auto-recover and offer /retry if the model stalls.\n`
886
+ : `\n`)));
828
887
  spinner.start();
829
888
  spinner.text = 'Waiting for model response...';
830
889
  }, 5_000);
@@ -1406,7 +1465,7 @@ export class ChatCommand {
1406
1465
  if (!this.config.isAuthenticated()) {
1407
1466
  if (options.json) {
1408
1467
  process.exitCode = 1;
1409
- console.log(JSON.stringify({ success: false, error: 'Not authenticated. Run: vigthoria login' }, null, 2));
1468
+ emitAgentJsonOutput({ success: false, error: 'Not authenticated. Run: vigthoria login' });
1410
1469
  }
1411
1470
  else {
1412
1471
  this.logger.error('Not authenticated. Run: vigthoria login');
@@ -1422,7 +1481,7 @@ export class ChatCommand {
1422
1481
  this.jsonOutput = options.json === true;
1423
1482
  this.autoApprove = options.autoApprove === true || this.jsonOutput;
1424
1483
  this.personaOverride = options.grant === true ? 'wiener_grant' : null;
1425
- this.modelExplicitlySelected = Boolean(String(options.model || '').trim());
1484
+ this.modelExplicitlySelected = this.isHardExplicitAgentModelSelection(options);
1426
1485
  this.currentModel = this.resolveInitialModel(options);
1427
1486
  this.applyNoAgentGovernance(String(options.model || this.currentModel || ''));
1428
1487
  this.currentProjectPath = this.resolveProjectPath(options);
@@ -2788,6 +2847,28 @@ export class ChatCommand {
2788
2847
  console.log(chalk.gray(' Run: vigthoria preview'));
2789
2848
  this.printAgentRunSummary(this.lastAgentRunOutcome, evaluation, 1);
2790
2849
  }
2850
+ else {
2851
+ const routingPolicy = this.resolveAgentExecutionPolicy(prompt);
2852
+ console.log(JSON.stringify({
2853
+ success: evaluation.executorSucceeded,
2854
+ mode: 'agent',
2855
+ model: routingPolicy.selectedModel,
2856
+ routingPolicy,
2857
+ taskId: null,
2858
+ contextId: null,
2859
+ partial: false,
2860
+ content: summary,
2861
+ statusHeadline: evaluation.statusHeadline,
2862
+ tasksSucceeded: 1,
2863
+ tasksTotal: 1,
2864
+ metadata: {
2865
+ source: 'template-instant',
2866
+ templateName: result.templateName,
2867
+ processingMs: result.processingMs,
2868
+ route: this.lastAgentRoute,
2869
+ },
2870
+ }, null, 2));
2871
+ }
2791
2872
  return true;
2792
2873
  }
2793
2874
  async tryDirectSingleFileFlow(prompt) {
@@ -3328,7 +3409,8 @@ export class ChatCommand {
3328
3409
  console.log(this.renderAgentAnswerForTerminal(finalAnswer));
3329
3410
  }
3330
3411
  }
3331
- if (!executorSucceeded && requiresWorkspaceChanges && changedFileCount === 0 && !liveOutcome.executorError) {
3412
+ const plannerFailedBeforeTasks = Boolean(liveOutcome.plannerError) && liveOutcome.tasksTotal === 0;
3413
+ if (!executorSucceeded && requiresWorkspaceChanges && changedFileCount === 0 && !liveOutcome.executorError && !plannerFailedBeforeTasks) {
3332
3414
  const clientToolErrors = this.api.getClientToolErrors();
3333
3415
  const transportErrors = this.api.getLastChatTransportErrors();
3334
3416
  if (clientToolErrors.length > 0) {
@@ -3344,6 +3426,12 @@ export class ChatCommand {
3344
3426
  liveOutcome.plannerError = transportErrors[0];
3345
3427
  }
3346
3428
  }
3429
+ if (plannerFailedBeforeTasks && !liveOutcome.executorError) {
3430
+ const transportErrors = this.api.getLastChatTransportErrors();
3431
+ if (transportErrors.length > 0) {
3432
+ liveOutcome.executorError = transportErrors[0];
3433
+ }
3434
+ }
3347
3435
  if (executorSucceeded) {
3348
3436
  taskDisplay.complete(1);
3349
3437
  }
@@ -3437,7 +3525,7 @@ export class ChatCommand {
3437
3525
  if (!executorSucceeded) {
3438
3526
  process.exitCode = 1;
3439
3527
  }
3440
- console.log(JSON.stringify({
3528
+ emitAgentJsonOutput({
3441
3529
  success: executorSucceeded,
3442
3530
  mode: 'agent',
3443
3531
  model: routingPolicy.selectedModel,
@@ -3459,7 +3547,7 @@ export class ChatCommand {
3459
3547
  },
3460
3548
  previewGate,
3461
3549
  },
3462
- }, null, 2));
3550
+ });
3463
3551
  }
3464
3552
  this.messages.push({ role: 'assistant', content: finalAnswer || runEvaluation.statusHeadline });
3465
3553
  this.stopV3IdleWatch();
package/dist/index.js CHANGED
@@ -54,6 +54,8 @@ const __filename = fileURLToPath(import.meta.url);
54
54
  const __dirname = path.dirname(__filename);
55
55
  import { APIClient, sanitizeUserFacingErrorText } from './utils/api.js';
56
56
  import { getCliStateFile, isOfflineMode, isSafeNpmPackageSpec, isUpdateCheckSuppressed, readCachedLatestVersion, readGatewayPreflightCache, writeCachedLatestVersion, writeGatewayPreflightCache, } from './utils/cli-state.js';
57
+ import { applyLocalTestfarmDefaults, isLocalTestfarmMode } from './utils/localTestMode.js';
58
+ applyLocalTestfarmDefaults();
57
59
  function isApiError(error) {
58
60
  return Boolean(error &&
59
61
  typeof error === 'object' &&
@@ -398,6 +400,9 @@ function isAuthProtectedCommand(command) {
398
400
  return protectedCommands.has(command);
399
401
  }
400
402
  async function enforceGatewayAuthSession(config, logger, jsonOutputRequested) {
403
+ if (isLocalTestfarmMode()) {
404
+ return true;
405
+ }
401
406
  if (!config.isAuthenticated()) {
402
407
  return true;
403
408
  }
@@ -3,6 +3,13 @@
3
3
  */
4
4
  import { inferAgentTaskType, isTrivialHtmlPageRequest, isAgentContinuePrompt, isAgentRetryPrompt, hasGameIntent, hasWebPageIntent, taskRequiresWorkspaceChanges, stripExecutionShaping, } from './requestIntent.js';
5
5
  import { isFastInferenceRouterAvailable, routeWithBalanced4b, shouldUseBalancedRouter, } from './fastAgentRouter.js';
6
+ function forceTemplateScaffold() {
7
+ const v = String(process.env.VIGTHORIA_FORCE_TEMPLATE_SCAFFOLD || '').trim().toLowerCase();
8
+ return v === '1' || v === 'true' || v === 'yes';
9
+ }
10
+ function wantsInstantTemplatePath(prompt) {
11
+ return /instant[- ]template|template[- ]instant|use the instant-template path/i.test(prompt);
12
+ }
6
13
  function normalizeTaskKind(value, fallback) {
7
14
  const kind = String(value || fallback).trim().toLowerCase();
8
15
  const allowed = new Set([
@@ -115,6 +122,16 @@ export async function resolveAgentRoute(api, prompt) {
115
122
  };
116
123
  }
117
124
  if (hasGameIntent(stripped) && regexTask === 'game-build') {
125
+ if (forceTemplateScaffold() || wantsInstantTemplatePath(stripped)) {
126
+ return {
127
+ path: 'template-instant',
128
+ taskKind: 'game-build',
129
+ fastPath: 'template-instant',
130
+ confidence: 0.98,
131
+ reason: 'express game template scaffold',
132
+ source: 'regex',
133
+ };
134
+ }
118
135
  return {
119
136
  path: 'v3-agent',
120
137
  taskKind: 'game-build',
@@ -256,6 +256,7 @@ export declare class APIClient {
256
256
  valid: boolean;
257
257
  error?: string;
258
258
  }>;
259
+ private isDirectV3AgentBaseUrl;
259
260
  getV3AgentBaseUrls(preferLocal?: boolean): string[];
260
261
  getV3AgentRunUrl(baseUrl: string): string;
261
262
  getV3AgentContinueUrl(baseUrl: string): string;
package/dist/utils/api.js CHANGED
@@ -12,10 +12,12 @@ import WebSocket from 'ws';
12
12
  import { buildSemanticContext } from './context-ranker.js';
13
13
  import { getChangedFiles } from './workspace-cache.js';
14
14
  import { isSubstantiveAgentAnswer, isToolEvidenceStubAnswer } from './agentRunOutcome.js';
15
- import { joinV3WorkspacePath, normalizeV3WorkspaceRelativePath } from './v3-workspace-path.js';
15
+ import { isV3WorkspaceRootAliasPath, joinV3WorkspacePath, normalizeV3WorkspaceRelativePath } from './v3-workspace-path.js';
16
16
  import { buildExecutionHints, isPlannerBuildTask, resolvePlannerAgentTimeoutMs } from './requestIntent.js';
17
17
  import { isV3StreamKeepaliveEvent } from './v3-stream-events.js';
18
18
  import { resolveV3ContextCharLimit, WORKSPACE_FILE_CHAR_CAP, } from './contextBudget.js';
19
+ import { isLocalTestfarmMode, fetchWithServiceTimeout } from './localTestMode.js';
20
+ import { buildClientManifest } from './clientManifest.js';
19
21
  export const VIGTHORIA_HUB_CREDITS_URL = 'https://hub.vigthoria.io/credits';
20
22
  export const VIGTHORIA_SERVER_TEMPORARILY_UNAVAILABLE_MESSAGE = 'Vigthoria Server is temporarily not available. Please try again later. If the issue persists, please contact support.';
21
23
  export class CLIError extends Error {
@@ -258,21 +260,25 @@ export function propagateError(err) {
258
260
  },
259
261
  };
260
262
  }
263
+ // Production safety net — see matching comment in src/commands/chat.ts.
264
+ // These previously defaulted to 0 (disabled), letting a stalled model stream
265
+ // hang the CLI forever with no abort. Set the env vars to 0 to restore the
266
+ // old unbounded behavior for debugging.
261
267
  const DEFAULT_V3_AGENT_TIMEOUT_MS = (() => {
262
268
  const rawValue = process.env.VIGTHORIA_AGENT_TIMEOUT_MS || process.env.V3_AGENT_TIMEOUT_MS;
263
269
  if (!rawValue) {
264
- return 0;
270
+ return 1_200_000; // 20 min hard cap on the whole agent request
265
271
  }
266
272
  const parsed = Number.parseInt(rawValue, 10);
267
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
273
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 1_200_000;
268
274
  })();
269
275
  const DEFAULT_V3_AGENT_IDLE_TIMEOUT_MS = (() => {
270
276
  const rawValue = process.env.VIGTHORIA_AGENT_IDLE_TIMEOUT_MS || process.env.V3_AGENT_IDLE_TIMEOUT_MS;
271
277
  if (!rawValue) {
272
- return 0;
278
+ return 60_000; // 60s with zero new stream data => abort/retry
273
279
  }
274
280
  const parsed = Number.parseInt(rawValue, 10);
275
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
281
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 60_000;
276
282
  })();
277
283
  const DEFAULT_OPERATOR_TIMEOUT_MS = (() => {
278
284
  const rawValue = process.env.VIGTHORIA_OPERATOR_TIMEOUT_MS || process.env.OPERATOR_TIMEOUT_MS;
@@ -559,6 +565,16 @@ export class APIClient {
559
565
  const enforceTokenShape = options.enforceTokenShape !== false;
560
566
  const explicitEnvToken = Boolean(process.env.VIGTHORIA_TOKEN || process.env.VIGTHORIA_AUTH_TOKEN);
561
567
  const token = this.getAccessToken();
568
+ if (isLocalTestfarmMode()) {
569
+ if (!token) {
570
+ return { valid: false, error: 'Local test mode requires VIGTHORIA_LOCAL_TEST_TOKEN or VIGTHORIA_AUTH_TOKEN' };
571
+ }
572
+ const expected = String(process.env.VIGTHORIA_LOCAL_TEST_TOKEN || '').trim();
573
+ if (expected && token !== expected) {
574
+ return { valid: false, error: 'VIGTHORIA_LOCAL_TEST_TOKEN mismatch' };
575
+ }
576
+ return { valid: true };
577
+ }
562
578
  if (!token) {
563
579
  return { valid: false, error: 'No auth token configured. Run: vigthoria login' };
564
580
  }
@@ -617,8 +633,18 @@ export class APIClient {
617
633
  // Both unreachable — don't assume the stored token is bad when running offline.
618
634
  return { valid: true };
619
635
  }
636
+ isDirectV3AgentBaseUrl(baseUrl) {
637
+ try {
638
+ const parsed = new URL(String(baseUrl));
639
+ return parsed.port === '8030' || parsed.port.endsWith('8030');
640
+ }
641
+ catch {
642
+ return /(?:127\.0\.0\.1|localhost|172\.19\.0\.1|host\.lan):(?:\d*8030|8030)/.test(String(baseUrl));
643
+ }
644
+ }
620
645
  getV3AgentBaseUrls(preferLocal = false) {
621
646
  const configuredApiUrl = String(this.config.get('apiUrl') || 'https://coder.vigthoria.io').replace(/\/$/, '');
647
+ const localTestMode = isLocalTestfarmMode();
622
648
  const includeLoopbackV3Agent = process.env.VIGTHORIA_ALLOW_LOCAL_V3_AGENT === '1'
623
649
  || (preferLocal && isServerRuntime());
624
650
  const localCandidates = [
@@ -626,30 +652,32 @@ export class APIClient {
626
652
  process.env.V3_AGENT_URL,
627
653
  ...(includeLoopbackV3Agent ? ['http://127.0.0.1:8030'] : []),
628
654
  ].filter(Boolean).map((url) => String(url).replace(/\/$/, ''));
629
- const remoteCandidates = [
630
- configuredApiUrl,
631
- 'https://coder.vigthoria.io',
632
- ].filter(Boolean).map((url) => String(url).replace(/\/$/, ''));
655
+ const remoteCandidates = localTestMode
656
+ ? [configuredApiUrl]
657
+ : [configuredApiUrl, 'https://coder.vigthoria.io'];
658
+ const normalizedRemote = remoteCandidates
659
+ .filter(Boolean)
660
+ .map((url) => String(url).replace(/\/$/, ''));
633
661
  const urls = preferLocal && localCandidates.length > 0
634
- ? [...localCandidates, ...remoteCandidates]
635
- : [...remoteCandidates, ...localCandidates];
662
+ ? [...localCandidates, ...normalizedRemote]
663
+ : [...normalizedRemote, ...localCandidates];
636
664
  return [...new Set(urls)];
637
665
  }
638
666
  getV3AgentRunUrl(baseUrl) {
639
- if (/127\.0\.0\.1:8030|localhost:8030/.test(baseUrl)) {
667
+ if (this.isDirectV3AgentBaseUrl(baseUrl)) {
640
668
  return `${baseUrl}/api/agent/run`;
641
669
  }
642
670
  return `${baseUrl}/api/v3-agent/run`;
643
671
  }
644
672
  getV3AgentContinueUrl(baseUrl) {
645
- if (/127\.0\.0\.1:8030|localhost:8030/.test(baseUrl)) {
673
+ if (this.isDirectV3AgentBaseUrl(baseUrl)) {
646
674
  return `${baseUrl}/api/agent/continue`;
647
675
  }
648
676
  return `${baseUrl}/api/v3-agent/continue`;
649
677
  }
650
678
  getV3AgentBackgroundUrl(baseUrl, suffix = '') {
651
679
  const cleanSuffix = suffix ? `/${suffix.replace(/^\/+/, '')}` : '';
652
- if (/127\.0\.0\.1:8030|localhost:8030/.test(baseUrl)) {
680
+ if (this.isDirectV3AgentBaseUrl(baseUrl)) {
653
681
  return `${baseUrl}/api/agent/background${cleanSuffix}`;
654
682
  }
655
683
  return `${baseUrl}/api/v3-agent/background${cleanSuffix}`;
@@ -670,13 +698,20 @@ export class APIClient {
670
698
  }
671
699
  getMcpBaseUrls() {
672
700
  const configuredApiUrl = String(this.config.get('apiUrl') || 'https://coder.vigthoria.io').replace(/\/$/, '');
673
- const urls = [
674
- process.env.VIGTHORIA_MCP_URL,
675
- process.env.MCP_SERVER_URL,
676
- 'http://127.0.0.1:4008',
677
- configuredApiUrl,
678
- ].filter(Boolean).map((url) => String(url).replace(/\/$/, ''));
679
- return [...new Set(urls)];
701
+ const localTestMode = isLocalTestfarmMode();
702
+ const urls = localTestMode
703
+ ? [
704
+ process.env.VIGTHORIA_MCP_URL,
705
+ process.env.MCP_SERVER_URL,
706
+ configuredApiUrl,
707
+ ]
708
+ : [
709
+ process.env.VIGTHORIA_MCP_URL,
710
+ process.env.MCP_SERVER_URL,
711
+ 'http://127.0.0.1:4008',
712
+ configuredApiUrl,
713
+ ];
714
+ return [...new Set(urls.filter(Boolean).map((url) => String(url).replace(/\/$/, '')))];
680
715
  }
681
716
  getVigFlowBaseUrls() {
682
717
  const configuredApiUrl = String(this.config.get('apiUrl') || 'https://coder.vigthoria.io').replace(/\/$/, '');
@@ -1165,44 +1200,52 @@ export class APIClient {
1165
1200
  async runV3HealthCheck() {
1166
1201
  const endpoints = this.getV3AgentBaseUrls(false);
1167
1202
  const headers = await this.getV3AgentHeaders();
1203
+ const failures = [];
1168
1204
  for (const baseUrl of endpoints) {
1169
1205
  const runUrl = this.getV3AgentRunUrl(baseUrl);
1170
1206
  // Use the lightweight GET /health endpoint — no LLM invocation, responds instantly.
1171
1207
  // For local 8030 the path is /health directly; for remote it's /api/v3-agent/health via proxy.
1172
- const healthUrl = /127\.0\.0\.1:8030|localhost:8030/.test(baseUrl)
1208
+ const healthUrl = this.isDirectV3AgentBaseUrl(baseUrl)
1173
1209
  ? `${baseUrl}/health`
1174
1210
  : `${baseUrl}/api/v3-agent/health`;
1175
- try {
1176
- const response = await fetch(healthUrl, {
1177
- method: 'GET',
1178
- headers,
1179
- signal: AbortSignal.timeout(8000),
1180
- });
1181
- if (response.status === 401 || response.status === 403) {
1182
- return {
1183
- healthy: false,
1184
- endpoint: runUrl,
1185
- error: 'V3 service rejected authentication. Please re-login to refresh your token.',
1186
- };
1211
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
1212
+ try {
1213
+ const response = await fetch(healthUrl, {
1214
+ method: 'GET',
1215
+ headers,
1216
+ signal: AbortSignal.timeout(8000),
1217
+ });
1218
+ if (response.status === 401 || response.status === 403) {
1219
+ return {
1220
+ healthy: false,
1221
+ endpoint: runUrl,
1222
+ error: 'V3 service rejected authentication. Please re-login to refresh your token.',
1223
+ };
1224
+ }
1225
+ // 200 OK or 405 Method Not Allowed both mean V3 is reachable.
1226
+ if (response.ok || response.status === 405) {
1227
+ return { healthy: true, endpoint: runUrl };
1228
+ }
1229
+ failures.push(`${healthUrl} attempt ${attempt}: HTTP ${response.status}`);
1230
+ }
1231
+ catch (error) {
1232
+ const reason = sanitizeUserFacingErrorText(error?.message || String(error)).slice(0, 160);
1233
+ failures.push(`${healthUrl} attempt ${attempt}: ${reason || 'network error'}`);
1187
1234
  }
1188
- // 200 OK or 405 Method Not Allowed both mean V3 is reachable
1189
- if (response.ok || response.status === 405) {
1190
- return { healthy: true, endpoint: runUrl };
1235
+ if (attempt < 3) {
1236
+ await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
1191
1237
  }
1192
1238
  }
1193
- catch {
1194
- continue;
1195
- }
1196
1239
  }
1197
1240
  return {
1198
1241
  healthy: false,
1199
1242
  endpoint: endpoints[0] ? this.getV3AgentRunUrl(endpoints[0]) : 'unknown',
1200
- error: 'V3 service is not reachable during startup preflight. Check if V3 Code Agent is online and reachable.',
1243
+ error: `V3 service is not reachable during startup preflight. Checked ${endpoints.length} endpoint(s). ${failures.slice(-3).join(' | ')}`,
1201
1244
  };
1202
1245
  }
1203
1246
  async runV3AgentAuthPreflight(baseUrl, body, executionContext) {
1204
1247
  const endpoint = this.getV3AgentRunUrl(baseUrl);
1205
- const healthEndpoint = /127\.0\.0\.1:8030|localhost:8030/.test(baseUrl)
1248
+ const healthEndpoint = this.isDirectV3AgentBaseUrl(baseUrl)
1206
1249
  ? `${baseUrl}/health`
1207
1250
  : `${baseUrl}/api/v3-agent/health`;
1208
1251
  const headers = await this.getV3AgentHeaders();
@@ -1579,6 +1622,9 @@ export class APIClient {
1579
1622
  codebaseContext: resolvedContext.codebaseContext || '',
1580
1623
  accountBrainContext: resolvedContext.accountBrainContext || '',
1581
1624
  projectMemory: resolvedContext.projectMemory || '',
1625
+ clientManifest: buildClientManifest(resolvedContext.clientManifest && typeof resolvedContext.clientManifest === 'object'
1626
+ ? resolvedContext.clientManifest
1627
+ : {}),
1582
1628
  };
1583
1629
  return this.compactV3Context(payload);
1584
1630
  }
@@ -2853,7 +2899,10 @@ menu {
2853
2899
  }
2854
2900
  }
2855
2901
  if (!relativePath && raw !== '.' && raw !== '') {
2856
- return null;
2902
+ const treatAsWorkspaceRoot = roots.some((sourceRoot) => isV3WorkspaceRootAliasPath(raw, sourceRoot));
2903
+ if (!treatAsWorkspaceRoot) {
2904
+ return null;
2905
+ }
2857
2906
  }
2858
2907
  const absolutePath = relativePath ? joinV3WorkspacePath(rootPath, relativePath) : path.resolve(rootPath);
2859
2908
  const resolvedRoot = path.resolve(rootPath);
@@ -3776,6 +3825,14 @@ document.addEventListener('DOMContentLoaded', () => {
3776
3825
  else {
3777
3826
  chunk = await new Promise((resolve, reject) => {
3778
3827
  let idleTimer = null;
3828
+ // Hard backstop for the reschedule branches below: even when the
3829
+ // workspace looks empty (nothing to recover yet) or client tools
3830
+ // are pending, we must not reschedule forever. Cap total
3831
+ // unproductive idle time at 10x the idle window (10 min at the
3832
+ // 60s default) before giving up with a clean timeout instead of
3833
+ // hanging the CLI indefinitely (TestFarm incident 2026-07-04).
3834
+ let idleRescheduleCount = 0;
3835
+ const maxIdleReschedules = 10;
3779
3836
  const clearIdleTimer = () => {
3780
3837
  if (idleTimer) {
3781
3838
  clearTimeout(idleTimer);
@@ -3785,7 +3842,8 @@ document.addEventListener('DOMContentLoaded', () => {
3785
3842
  const scheduleIdleTimer = () => {
3786
3843
  clearIdleTimer();
3787
3844
  idleTimer = setTimeout(() => {
3788
- if (this.pendingV3ClientToolTasks.size > 0) {
3845
+ if (this.pendingV3ClientToolTasks.size > 0 && idleRescheduleCount < maxIdleReschedules) {
3846
+ idleRescheduleCount += 1;
3789
3847
  scheduleIdleTimer();
3790
3848
  return;
3791
3849
  }
@@ -3803,6 +3861,22 @@ document.addEventListener('DOMContentLoaded', () => {
3803
3861
  reject(stalledError);
3804
3862
  return;
3805
3863
  }
3864
+ if (idleRescheduleCount >= maxIdleReschedules) {
3865
+ const timeoutError = new Error(`V3 agent stream timed out after ${Math.round((idleTimeoutMs * (maxIdleReschedules + 1)) / 1000)}s with no data ` +
3866
+ '(model likely stopped after a thinking block without emitting a tool call or final answer).');
3867
+ timeoutError.name = 'AbortError';
3868
+ timeoutError.partialData = {
3869
+ task_id: events.find((event) => event && event.task_id)?.task_id || null,
3870
+ context_id: contextId,
3871
+ result: final,
3872
+ events,
3873
+ files: streamedFiles,
3874
+ };
3875
+ clearIdleTimer();
3876
+ reject(timeoutError);
3877
+ return;
3878
+ }
3879
+ idleRescheduleCount += 1;
3806
3880
  scheduleIdleTimer();
3807
3881
  }, idleTimeoutMs);
3808
3882
  };
@@ -4382,6 +4456,8 @@ document.addEventListener('DOMContentLoaded', () => {
4382
4456
  // ground its analysis in real files rather than returning files_analyzed: 0.
4383
4457
  const workspacePath = executionContext.workspacePath || executionContext.projectPath || executionContext.targetPath || process.cwd();
4384
4458
  const workspaceSummary = this.buildLocalWorkspaceSummary(workspacePath);
4459
+ const requestedModel = String(executionContext.model || 'code');
4460
+ const resolvedModel = this.resolveModelId(requestedModel);
4385
4461
  for (const baseUrl of this.getOperatorBaseUrls()) {
4386
4462
  const controller = new AbortController();
4387
4463
  const timeoutId = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null;
@@ -4401,6 +4477,8 @@ document.addEventListener('DOMContentLoaded', () => {
4401
4477
  prompt: message,
4402
4478
  task: message,
4403
4479
  raw_prompt: executionContext.rawPrompt || null,
4480
+ model: requestedModel,
4481
+ preferred_model: resolvedModel,
4404
4482
  context_id: executionContext.contextId,
4405
4483
  trace_id: executionContext.traceId,
4406
4484
  mcp_context_id: executionContext.mcpContextId || null,
@@ -4408,7 +4486,7 @@ document.addEventListener('DOMContentLoaded', () => {
4408
4486
  workspace: { path: workspacePath },
4409
4487
  workspace_path: workspacePath,
4410
4488
  workspace_summary: workspaceSummary,
4411
- model: this.resolveModelId(executionContext.model || 'code'),
4489
+ model: resolvedModel,
4412
4490
  history: executionContext.history || [],
4413
4491
  executionSurface: executionContext.executionSurface || 'cli',
4414
4492
  clientSurface: executionContext.clientSurface || 'cli',
@@ -5915,7 +5993,7 @@ document.addEventListener('DOMContentLoaded', () => {
5915
5993
  return;
5916
5994
  }
5917
5995
  const headers = await this.getV3AgentHeaders();
5918
- const endpoint = /127\.0\.0\.1:8030|localhost:8030/.test(baseUrl)
5996
+ const endpoint = this.isDirectV3AgentBaseUrl(baseUrl)
5919
5997
  ? `${baseUrl}/api/agent/client-tool-result`
5920
5998
  : `${baseUrl}/api/v3-agent/client-tool-result`;
5921
5999
  const body = JSON.stringify({
@@ -6132,7 +6210,7 @@ document.addEventListener('DOMContentLoaded', () => {
6132
6210
  const endpoint = process.env.VIGTHORIA_HYPERLOOP_URL || `${configuredApiUrl}/api/hyperloop/health`;
6133
6211
  try {
6134
6212
  const token = this.getAccessToken();
6135
- const response = await fetch(endpoint, {
6213
+ const response = await fetchWithServiceTimeout(endpoint, {
6136
6214
  method: 'GET',
6137
6215
  headers: token ? { Authorization: `Bearer ${token}` } : undefined,
6138
6216
  });
@@ -6164,7 +6242,7 @@ document.addEventListener('DOMContentLoaded', () => {
6164
6242
  const token = this.getAccessToken();
6165
6243
  const projectPath = this.resolveAgentTargetPath(context);
6166
6244
  try {
6167
- const modulesResponse = await fetch(modulesEndpoint, {
6245
+ const modulesResponse = await fetchWithServiceTimeout(modulesEndpoint, {
6168
6246
  method: 'GET',
6169
6247
  headers: {
6170
6248
  ...(token ? { Authorization: `Bearer ${token}` } : {}),
@@ -6178,7 +6256,7 @@ document.addEventListener('DOMContentLoaded', () => {
6178
6256
  const compactor = modules.find((entry) => entry && entry.name === 'repo_context_compactor');
6179
6257
  let compactContextLength = 0;
6180
6258
  try {
6181
- const probeResponse = await fetch(endpoint, {
6259
+ const probeResponse = await fetchWithServiceTimeout(endpoint, {
6182
6260
  method: 'POST',
6183
6261
  headers: {
6184
6262
  'Content-Type': 'application/json',
@@ -6260,6 +6338,33 @@ document.addEventListener('DOMContentLoaded', () => {
6260
6338
  const host = process.env.VIGTHORIA_DEVTOOLS_BRIDGE_HOST || '127.0.0.1';
6261
6339
  const port = Number.parseInt(process.env.VIGTHORIA_DEVTOOLS_BRIDGE_PORT || '4016', 10);
6262
6340
  const endpoint = `ws://${host}:${port}/ws`;
6341
+ const healthEndpoint = `http://${host}:${port}/health`;
6342
+ try {
6343
+ const controller = new AbortController();
6344
+ const timer = setTimeout(() => controller.abort(), 1500);
6345
+ const response = await fetch(healthEndpoint, {
6346
+ signal: controller.signal,
6347
+ headers: { Accept: 'application/json' },
6348
+ });
6349
+ clearTimeout(timer);
6350
+ if (response.ok) {
6351
+ return {
6352
+ name: 'DevTools Bridge',
6353
+ endpoint,
6354
+ ok: true,
6355
+ details: { host, port, healthEndpoint },
6356
+ };
6357
+ }
6358
+ return {
6359
+ name: 'DevTools Bridge',
6360
+ endpoint,
6361
+ ok: false,
6362
+ error: `HTTP ${response.status}`,
6363
+ };
6364
+ }
6365
+ catch {
6366
+ // Fall back to a raw TCP probe for older bridge builds without /health.
6367
+ }
6263
6368
  return new Promise((resolve) => {
6264
6369
  const socket = net.connect({ host, port, timeout: 1500 }, () => {
6265
6370
  socket.end();
@@ -0,0 +1,12 @@
1
+ export type ClientManifestPayload = {
2
+ product_id: string;
3
+ version: string;
4
+ os: string;
5
+ execution_mode: string;
6
+ features_allowed: string[];
7
+ };
8
+ /** Detect iSH/Blink-style iOS terminal sandboxes (override, UA, or Linux-on-iOS release string). */
9
+ export declare function isIOSEnvironment(): boolean;
10
+ /** Map Node process.platform/arch to dispatcher gate OS slug. */
11
+ export declare function resolveClientOsSlug(): string;
12
+ export declare function buildClientManifest(overrides?: Partial<ClientManifestPayload>): ClientManifestPayload;
@@ -0,0 +1,68 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = path.dirname(__filename);
7
+ const DEFAULT_FEATURES = ['template-instant', 'hyperloop-stream'];
8
+ function readPackageVersion() {
9
+ const candidates = [
10
+ path.join(__dirname, '..', '..', 'package.json'),
11
+ path.join(__dirname, '..', 'package.json'),
12
+ ];
13
+ for (const candidate of candidates) {
14
+ try {
15
+ if (!fs.existsSync(candidate))
16
+ continue;
17
+ const parsed = JSON.parse(fs.readFileSync(candidate, 'utf8'));
18
+ if (parsed.version)
19
+ return String(parsed.version);
20
+ }
21
+ catch {
22
+ // try next candidate
23
+ }
24
+ }
25
+ return '0.0.0';
26
+ }
27
+ /** Detect iSH/Blink-style iOS terminal sandboxes (override, UA, or Linux-on-iOS release string). */
28
+ export function isIOSEnvironment() {
29
+ const override = (process.env.VIGTHORIA_OS_OVERRIDE || '').trim().toLowerCase();
30
+ if (override === 'ios')
31
+ return true;
32
+ const userAgent = (process.env.VIGTHORIA_CLIENT_USER_AGENT || '').trim();
33
+ if (/ipad|iphone|ios/i.test(userAgent))
34
+ return true;
35
+ if (process.platform === 'linux') {
36
+ const release = os.release().toLowerCase();
37
+ if (/ipad|iphone|ios/.test(release))
38
+ return true;
39
+ }
40
+ return false;
41
+ }
42
+ /** Map Node process.platform/arch to dispatcher gate OS slug. */
43
+ export function resolveClientOsSlug() {
44
+ if (isIOSEnvironment()) {
45
+ return process.arch === 'arm64' ? 'ios-arm64' : 'ios-x64';
46
+ }
47
+ if (process.platform === 'win32')
48
+ return 'windows-x86_64';
49
+ if (process.platform === 'darwin') {
50
+ return process.arch === 'arm64' ? 'macos-arm64' : 'macos-x86_64';
51
+ }
52
+ if (process.platform === 'linux') {
53
+ return process.arch === 'arm64' ? 'linux-arm64' : 'linux-x86_64';
54
+ }
55
+ return `${process.platform}-${process.arch}`;
56
+ }
57
+ export function buildClientManifest(overrides = {}) {
58
+ const ios = isIOSEnvironment() && !overrides.os;
59
+ return {
60
+ product_id: overrides.product_id || 'vigthoria-cli',
61
+ version: overrides.version || readPackageVersion(),
62
+ os: overrides.os || resolveClientOsSlug(),
63
+ execution_mode: overrides.execution_mode || (ios ? 'mobile-terminal-sandbox' : 'local-user-machine'),
64
+ features_allowed: overrides.features_allowed?.length
65
+ ? [...overrides.features_allowed]
66
+ : [...DEFAULT_FEATURES],
67
+ };
68
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Testfarm / Admin Factory local E2E — bypass public coder.vigthoria.io gateway
3
+ * when VIGTHORIA_LOCAL_TEST_MODE=1 or VIGTHORIA_LOCAL_TEST_TOKEN is set.
4
+ */
5
+ export declare function isLocalTestfarmMode(): boolean;
6
+ /** Apply once at CLI startup — idempotent via ??= */
7
+ export declare function applyLocalTestfarmDefaults(): void;
8
+ export declare function resolveHyperloopTimeoutMs(): number;
9
+ export declare function fetchWithServiceTimeout(url: string, init?: RequestInit): Promise<Response>;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Testfarm / Admin Factory local E2E — bypass public coder.vigthoria.io gateway
3
+ * when VIGTHORIA_LOCAL_TEST_MODE=1 or VIGTHORIA_LOCAL_TEST_TOKEN is set.
4
+ */
5
+ export function isLocalTestfarmMode() {
6
+ const flag = String(process.env.VIGTHORIA_LOCAL_TEST_MODE || '').trim().toLowerCase();
7
+ if (flag === '1' || flag === 'true' || flag === 'yes') {
8
+ return true;
9
+ }
10
+ return Boolean(String(process.env.VIGTHORIA_LOCAL_TEST_TOKEN || '').trim());
11
+ }
12
+ /** Apply once at CLI startup — idempotent via ??= */
13
+ export function applyLocalTestfarmDefaults() {
14
+ if (!isLocalTestfarmMode()) {
15
+ return;
16
+ }
17
+ process.env.VIGTHORIA_ALLOW_LOCAL_SERVICES ??= '1';
18
+ process.env.VIGTHORIA_ALLOW_LOCAL_V3_AGENT ??= '1';
19
+ process.env.VIGTHORIA_SELF_HOSTED_MODELS_API_URL ??= 'http://127.0.0.1:4009';
20
+ process.env.VIGTHORIA_HYPERLOOP_URL ??= 'http://127.0.0.1:8020';
21
+ process.env.VIGTHORIA_HYPERLOOP_EXECUTE_URL ??= 'http://127.0.0.1:8020/execute';
22
+ process.env.VIGTHORIA_HYPERLOOP_MODULES_URL ??= 'http://127.0.0.1:8020/modules';
23
+ process.env.VIGTHORIA_V3_AGENT_URL ??= 'http://127.0.0.1:8030';
24
+ process.env.VIGTHORIA_TEMPLATE_SERVICE_URL ??= 'http://127.0.0.1:4011';
25
+ process.env.VIGTHORIA_HYPERLOOP_TIMEOUT_MS ??= '180000';
26
+ const testToken = String(process.env.VIGTHORIA_LOCAL_TEST_TOKEN || '').trim();
27
+ if (testToken && !process.env.VIGTHORIA_AUTH_TOKEN && !process.env.VIGTHORIA_TOKEN) {
28
+ process.env.VIGTHORIA_AUTH_TOKEN = testToken;
29
+ }
30
+ }
31
+ export function resolveHyperloopTimeoutMs() {
32
+ const raw = process.env.VIGTHORIA_HYPERLOOP_TIMEOUT_MS
33
+ || process.env.VIGTHORIA_HTTP_TIMEOUT_MS
34
+ || '120000';
35
+ const parsed = Number.parseInt(String(raw), 10);
36
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 120000;
37
+ }
38
+ export async function fetchWithServiceTimeout(url, init = {}) {
39
+ const ms = resolveHyperloopTimeoutMs();
40
+ const signal = init.signal ?? AbortSignal.timeout(ms);
41
+ return fetch(url, { ...init, signal });
42
+ }
@@ -83,6 +83,7 @@ export declare class SessionManager {
83
83
  * Load session by ID
84
84
  */
85
85
  load(id: string): Session | null;
86
+ private normalizeProjectKey;
86
87
  /**
87
88
  * Get the most recent session for a project
88
89
  */
@@ -251,15 +251,48 @@ export class SessionManager {
251
251
  return null;
252
252
  }
253
253
  }
254
+ normalizeProjectKey(projectPath) {
255
+ const raw = String(projectPath || '').trim();
256
+ if (!raw) {
257
+ return '';
258
+ }
259
+ // Normalize separators and collapse trailing slashes.
260
+ let normalized = raw.replace(/\\/g, '/').replace(/\/+$/g, '');
261
+ // Canonicalize obvious Windows drive prefix casing (C:/... === c:/...).
262
+ const driveMatch = normalized.match(/^([a-zA-Z]):\//);
263
+ if (driveMatch) {
264
+ normalized = `${driveMatch[1].toUpperCase()}:${normalized.slice(2)}`;
265
+ }
266
+ // Windows paths are case-insensitive in practice for this CLI matching purpose.
267
+ if (/^[a-zA-Z]:\//.test(normalized) || normalized.startsWith('//')) {
268
+ return normalized.toLowerCase();
269
+ }
270
+ return normalized;
271
+ }
254
272
  /**
255
273
  * Get the most recent session for a project
256
274
  */
257
275
  getLatest(project) {
258
276
  const sessions = this.list();
259
- const projectSessions = sessions
260
- .filter(s => s.project === project)
277
+ const projectKey = this.normalizeProjectKey(project);
278
+ const projectBase = path.basename(projectKey || '').toLowerCase();
279
+ const exactMatches = sessions
280
+ .filter((s) => this.normalizeProjectKey(s.project) === projectKey)
261
281
  .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
262
- return projectSessions.length > 0 ? this.load(projectSessions[0].id) : null;
282
+ if (exactMatches.length > 0) {
283
+ return this.load(exactMatches[0].id);
284
+ }
285
+ // Fallback: if old sessions stored inconsistent slash/case forms, allow
286
+ // latest same-folder match (e.g., C:\Proj and C:/proj) to preserve /retry UX.
287
+ if (projectBase) {
288
+ const softMatches = sessions
289
+ .filter((s) => path.basename(this.normalizeProjectKey(s.project) || '').toLowerCase() === projectBase)
290
+ .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
291
+ if (softMatches.length > 0) {
292
+ return this.load(softMatches[0].id);
293
+ }
294
+ }
295
+ return null;
263
296
  }
264
297
  /**
265
298
  * List all sessions (metadata only) — corrupt or unreadable files are
@@ -170,6 +170,24 @@ export async function runTemplateInstantPath(api, vision, workspacePath, options
170
170
  const target = path.join(workspacePath, entryPath);
171
171
  const cleanVision = stripExecutionShaping(vision);
172
172
  const preferMinimal = options.forceMinimalFallback === true;
173
+ const keepScaffold = String(process.env.VIGTHORIA_EXPRESS_KEEP_SCAFFOLD || process.env.CLI_EXPRESS_KEEP_SCAFFOLD || '').trim().toLowerCase();
174
+ if (['1', 'true', 'yes'].includes(keepScaffold)) {
175
+ try {
176
+ const stat = fs.statSync(target);
177
+ if (stat.isFile() && stat.size >= 8000) {
178
+ return {
179
+ ok: true,
180
+ entryPath,
181
+ templateName: 'express-preserved-scaffold',
182
+ confidence: 1,
183
+ processingMs: 0,
184
+ };
185
+ }
186
+ }
187
+ catch {
188
+ // fall through to normal template match
189
+ }
190
+ }
173
191
  let html;
174
192
  let usedFallback = false;
175
193
  let match = {};
@@ -5,3 +5,5 @@
5
5
  export declare function normalizeV3WorkspaceRelativePath(rawPath: string, rootPath?: string): string;
6
6
  /** Join workspace root + relative path without path.resolve URI corruption on Windows. */
7
7
  export declare function joinV3WorkspacePath(rootPath: string, relativePath: string): string;
8
+ /** True when rawPath points to workspace root via aliases or scrubbed placeholders. */
9
+ export declare function isV3WorkspaceRootAliasPath(rawPath: string, rootPath?: string): boolean;
@@ -1,4 +1,12 @@
1
1
  import path from 'path';
2
+ const WORKSPACE_PLACEHOLDER_SEGMENT = /\[(?:internal|Vigthoria service|Vigthoria Agent|Vigthoria storage|temp|home|workspace)\]/ig;
3
+ function stripInlineWorkspacePlaceholders(value) {
4
+ // Only strip placeholders when they were appended to a segment (e.g. pitfall[Vigthoria service]).
5
+ // Keep canonical scrubbed segments like /[internal]/ intact for dedicated mapping logic.
6
+ return String(value || '')
7
+ .replace(/(^|[^/])\[(?:internal|Vigthoria service|Vigthoria Agent|Vigthoria storage|temp|home|workspace)\](?=(?:\/|$))/ig, '$1')
8
+ .replace(/\/+$/g, '');
9
+ }
2
10
  /**
3
11
  * Normalize V3 SSE / tool paths to a safe workspace-relative POSIX path.
4
12
  * Decodes vigthoria:// boundary URIs from server scrubbing before local writes.
@@ -8,6 +16,7 @@ export function normalizeV3WorkspaceRelativePath(rawPath, rootPath) {
8
16
  if (!input) {
9
17
  return '';
10
18
  }
19
+ input = stripInlineWorkspacePlaceholders(input);
11
20
  const safeRelative = (candidate) => {
12
21
  const stripped = String(candidate || '').replace(/^\/+/, '');
13
22
  if (!stripped) {
@@ -43,7 +52,7 @@ export function normalizeV3WorkspaceRelativePath(rawPath, rootPath) {
43
52
  return null;
44
53
  };
45
54
  const stripScrubbedPlaceholder = (value) => {
46
- let candidate = String(value || '').replace(/^\/+/, '');
55
+ let candidate = stripInlineWorkspacePlaceholders(String(value || '')).replace(/^\/+/, '');
47
56
  // Hybrid paths models echo back after SSE scrubbing, e.g. C:/var/www/[internal]/game.js
48
57
  const hybridWin = candidate.match(/^[a-zA-Z]:\/(?:var\/)?www\/\[internal\](?:\/(.*))?$/i);
49
58
  if (hybridWin) {
@@ -70,7 +79,7 @@ export function normalizeV3WorkspaceRelativePath(rawPath, rootPath) {
70
79
  return safeRelative(match[groupIndex] || '');
71
80
  }
72
81
  }
73
- const bracketSplit = candidate.split(/\[(?:internal|Vigthoria service|Vigthoria Agent|temp|home|workspace)\]/i);
82
+ const bracketSplit = candidate.split(WORKSPACE_PLACEHOLDER_SEGMENT);
74
83
  if (bracketSplit.length > 1) {
75
84
  const tail = bracketSplit[bracketSplit.length - 1].replace(/^\/+/, '');
76
85
  return safeRelative(tail);
@@ -152,3 +161,34 @@ export function joinV3WorkspacePath(rootPath, relativePath) {
152
161
  const segments = relativePath.split('/').filter((segment) => segment && segment !== '.');
153
162
  return path.join(rootPath, ...segments);
154
163
  }
164
+ /** True when rawPath points to workspace root via aliases or scrubbed placeholders. */
165
+ export function isV3WorkspaceRootAliasPath(rawPath, rootPath) {
166
+ const original = String(rawPath || '').trim();
167
+ if (!original || original === '.') {
168
+ return true;
169
+ }
170
+ const sanitized = stripInlineWorkspacePlaceholders(original).replace(/\\/g, '/').trim();
171
+ if (!sanitized || sanitized === '.' || sanitized === '/') {
172
+ return true;
173
+ }
174
+ const candidate = sanitized.replace(/^\/+/, '');
175
+ if (!candidate) {
176
+ return true;
177
+ }
178
+ const lowered = candidate.toLowerCase();
179
+ if (lowered === 'workspace'
180
+ || lowered === '[workspace]'
181
+ || lowered === '[internal]'
182
+ || lowered === 'www/[internal]'
183
+ || lowered === 'var/www/[internal]'
184
+ || lowered === '[vigthoria service]'
185
+ || lowered === 'var/www/[vigthoria service]') {
186
+ return true;
187
+ }
188
+ const normalizedRoot = String(rootPath || '').trim().replace(/\\/g, '/').replace(/\/+$/g, '');
189
+ if (!normalizedRoot) {
190
+ return false;
191
+ }
192
+ const normalizedInput = sanitized.replace(/\/+$/g, '');
193
+ return normalizedInput.toLowerCase() === normalizedRoot.toLowerCase();
194
+ }
package/install.ps1 CHANGED
@@ -5,7 +5,7 @@
5
5
  $ErrorActionPreference = "Stop"
6
6
 
7
7
  # Configuration
8
- $CLI_VERSION = "1.11.25"
8
+ $CLI_VERSION = "1.11.42"
9
9
  $INSTALL_DIR = "$env:USERPROFILE\.vigthoria"
10
10
  $NPM_PACKAGE = "vigthoria-cli"
11
11
  $GIT_PACKAGE_URL = "git+https://market.vigthoria.io/vigthoria/vigthoria-cli.git"
package/install.sh CHANGED
@@ -26,7 +26,7 @@ else
26
26
  fi
27
27
 
28
28
  # Configuration
29
- CLI_VERSION="1.11.25"
29
+ CLI_VERSION="1.11.42"
30
30
  INSTALL_DIR="$HOME/.vigthoria"
31
31
  REPO_URL="https://market.vigthoria.io/vigthoria/vigthoria-cli"
32
32
  GIT_PACKAGE_URL="git+https://market.vigthoria.io/vigthoria/vigthoria-cli.git"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigthoria-cli",
3
- "version": "1.11.37",
3
+ "version": "1.11.43",
4
4
  "description": "Vigthoria Coder CLI - AI-powered terminal coding assistant",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -70,6 +70,7 @@
70
70
  "test:legion:billing:e2e": "npm run build && node scripts/test-legion-godmode-billing-e2e.js",
71
71
  "test:windows:v3-sync": "npm run build && node scripts/test-windows-v3-sync-recovery.js",
72
72
  "test:v3-workspace-path": "npm run build && node scripts/test-v3-workspace-path.js",
73
+ "test:session:project-match": "npm run build && node scripts/test-session-project-match.mjs",
73
74
  "test:v3-stream-mutation": "npm run build && node scripts/test-v3-stream-mutation.js",
74
75
  "test:context:budget": "npm run build && node scripts/test-context-budget.js",
75
76
  "test:pitfall:context": "npm run build && node scripts/test-pitfall-context-smoke.js"
@@ -122,7 +122,10 @@ PY
122
122
  echo "[7.5 policy] secured hyperloop endpoint"
123
123
  code=$(curl -s -o /dev/null -w "%{http_code}" https://coder.vigthoria.io/api/hyperloop/health)
124
124
  echo "unauth_hyperloop_code=$code"
125
- [[ "$code" == "401" ]]
125
+ # 401 = correct JSON auth error for API callers. 302 (redirect to /login) is
126
+ # also acceptable defense-in-depth: it still requires auth, just via the
127
+ # browser-session redirect path some legacy middleware uses.
128
+ [[ "$code" == "401" || "$code" == "302" ]]
126
129
 
127
130
  echo "[8.2] runtime symlink guard signals"
128
131
  rg -n "realpathSync|results.length >= maxFiles|pattern = kw.length <= 4" dist/utils/context-ranker.js >/tmp/vig-ranker-signals.txt